Compare commits

...

2 commits

42 changed files with 176972 additions and 70 deletions

View file

@ -41,6 +41,7 @@ serde_with = { version = "3.0.0", default-features = false, features = [
"macros",
] }
serde_plain = "1.0.0"
sha1 = "0.10.0"
rand = "0.8.0"
time = { version = "0.3.10", features = [
"macros",
@ -51,7 +52,7 @@ futures-util = "0.3.31"
ress = "0.11.0"
phf = "0.11.0"
phf_codegen = "0.11.0"
base64 = "0.22.0"
data-encoding = "2.0.0"
urlencoding = "2.1.0"
quick-xml = { version = "0.37.0", features = ["serialize"] }
tracing = { version = "0.1.0", features = ["log"] }
@ -105,11 +106,12 @@ serde.workspace = true
serde_json.workspace = true
serde_with.workspace = true
serde_plain.workspace = true
sha1.workspace = true
rand.workspace = true
time.workspace = true
ress.workspace = true
phf.workspace = true
base64.workspace = true
data-encoding.workspace = true
urlencoding.workspace = true
tracing.workspace = true
quick-xml = { workspace = true, optional = true }

View file

@ -21,6 +21,8 @@ RustyPipe is a fully featured Rust client for the public YouTube / YouTube Music
- **Search suggestions**
- **Trending**
- **URL resolver**
- **Subscriptions**
- **Playback history**
### YouTube Music
@ -34,6 +36,8 @@ RustyPipe is a fully featured Rust client for the public YouTube / YouTube Music
- **Moods/Genres**
- **Charts**
- **New** (albums, music videos)
- **Saved items**
- **Playback history**
## Getting started

View file

@ -39,6 +39,9 @@ pub async fn download_testfiles() {
search_playlists().await;
search_empty().await;
trending().await;
history().await;
subscriptions().await;
subscription_feed().await;
music_playlist().await;
music_playlist_cont().await;
@ -62,6 +65,11 @@ pub async fn download_testfiles() {
music_charts().await;
music_genres().await;
music_genre().await;
music_history().await;
music_saved_artists().await;
music_saved_albums().await;
music_saved_tracks().await;
music_saved_playlists().await;
}
const CLIENT_TYPES: [ClientType; 5] = [
@ -455,6 +463,36 @@ async fn trending() {
rp.query().trending().await.unwrap();
}
async fn history() {
let json_path = path!(*TESTFILES_DIR / "history" / "history.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().history().await.unwrap();
}
async fn subscriptions() {
let json_path = path!(*TESTFILES_DIR / "history" / "subscriptions.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().subscriptions().await.unwrap();
}
async fn subscription_feed() {
let json_path = path!(*TESTFILES_DIR / "history" / "subscription_feed.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().subscription_feed().await.unwrap();
}
async fn music_playlist() {
for (name, id) in [
("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk"),
@ -776,3 +814,53 @@ async fn music_genre() {
rp.query().music_genre(id).await.unwrap();
}
}
async fn music_history() {
let json_path = path!(*TESTFILES_DIR / "music_history" / "music_history.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().music_history().await.unwrap();
}
async fn music_saved_artists() {
let json_path = path!(*TESTFILES_DIR / "music_history" / "saved_artists.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().music_saved_artists().await.unwrap();
}
async fn music_saved_albums() {
let json_path = path!(*TESTFILES_DIR / "music_history" / "saved_albums.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().music_saved_albums().await.unwrap();
}
async fn music_saved_tracks() {
let json_path = path!(*TESTFILES_DIR / "music_history" / "saved_tracks.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().music_saved_tracks().await.unwrap();
}
async fn music_saved_playlists() {
let json_path = path!(*TESTFILES_DIR / "music_history" / "saved_playlists.json");
if json_path.exists() {
return;
}
let rp = rp_testfile(&json_path);
rp.query().music_saved_playlists().await.unwrap();
}

View file

@ -230,6 +230,7 @@ impl MapResponse<Channel<Paginator<VideoItem>>> for response::Channel {
mapper.ctoken,
visitor_data,
ContinuationEndpoint::Browse,
false,
);
Ok(MapResult {

174
src/client/history.rs Normal file
View file

@ -0,0 +1,174 @@
use std::fmt::Debug;
use serde::Serialize;
use crate::{
client::{response, ClientType, MapRespCtx, MapResponse, QBrowse, RustyPipeQuery},
error::{Error, ExtractionError},
model::{
paginator::{ContinuationEndpoint, Paginator},
ChannelItem, VideoItem,
},
serializer::MapResult,
};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct QHistorySearch<'a> {
browse_id: &'a str,
query: &'a str,
}
impl RustyPipeQuery {
/// Get a list of videos from YouTube which the current user recently played
///
/// Requires authentication cookies.
#[tracing::instrument(skip(self), level = "error")]
pub async fn history(&self) -> Result<Paginator<VideoItem>, Error> {
let request_body = QBrowse {
browse_id: "FEhistory",
};
self.clone()
.authenticated()
.execute_request::<response::History, _, _>(
ClientType::Desktop,
"history",
"",
"browse",
&request_body,
)
.await
}
/// Search the YouTube playback history of the current user
///
/// Requires authentication cookies.
#[tracing::instrument(skip(self), level = "error")]
pub async fn history_search<S: AsRef<str> + Debug>(
&self,
query: S,
) -> Result<Paginator<VideoItem>, Error> {
let query = query.as_ref();
let request_body = QHistorySearch {
browse_id: "FEhistory",
query,
};
self.clone()
.authenticated()
.execute_request::<response::History, _, _>(
ClientType::Desktop,
"history_search",
query,
"browse",
&request_body,
)
.await
}
/// Get a list of channels the current user subscribed to from YouTube
///
/// Requires authentication cookies.
#[tracing::instrument(skip(self), level = "error")]
pub async fn subscriptions(&self) -> Result<Paginator<ChannelItem>, Error> {
self.clone()
.authenticated()
.continuation(
"4qmFsgIqEgpGRWNoYW5uZWxzGgRrQUlDmgIVYnJvd3NlLWZlZWRGRWNoYW5uZWxz",
ContinuationEndpoint::Browse,
None,
)
.await
}
/// Get the YouTube subscription feed of the current user
///
/// Requires authentication cookies.
#[tracing::instrument(skip(self), level = "error")]
pub async fn subscription_feed(&self) -> Result<Paginator<VideoItem>, Error> {
let request_body = QBrowse {
browse_id: "FEsubscriptions",
};
self.clone()
.authenticated()
.execute_request::<response::History, _, _>(
ClientType::Desktop,
"subscription_feed",
"",
"browse",
&request_body,
)
.await
}
}
impl MapResponse<Paginator<VideoItem>> for response::History {
fn map_response(
self,
ctx: &MapRespCtx<'_>,
) -> Result<MapResult<Paginator<VideoItem>>, ExtractionError> {
let items = self
.contents
.two_column_browse_results_renderer
.contents
.into_iter()
.next()
.ok_or(ExtractionError::InvalidData(
"twoColumnBrowseResultsRenderer empty".into(),
))?
.tab_renderer
.content
.section_list_renderer
.contents;
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(ctx.lang);
mapper.map_response(items);
Ok(MapResult {
c: Paginator::new_ext(
None,
mapper.items,
mapper.ctoken,
None,
crate::model::paginator::ContinuationEndpoint::Browse,
true,
),
warnings: mapper.warnings,
})
}
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::BufReader};
use path_macro::path;
use rstest::rstest;
use crate::util::tests::TESTFILES;
use super::*;
#[rstest]
#[case::history("history")]
#[case::subscription_feed("subscription_feed")]
fn map_history(#[case] name: &str) {
let json_path = path!(*TESTFILES / "history" / format!("{name}.json"));
let json_file = File::open(json_path).unwrap();
let history: response::History =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res = history.map_response(&MapRespCtx::test("")).unwrap();
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(format!("map_{name}"), map_res.c, {
".items[].publish_date" => "[date]",
});
}
}

View file

@ -3,10 +3,12 @@
pub(crate) mod response;
mod channel;
mod history;
mod music_artist;
mod music_charts;
mod music_details;
mod music_genres;
mod music_history;
mod music_new;
mod music_playlist;
mod music_search;
@ -31,6 +33,7 @@ use once_cell::sync::Lazy;
use regex::Regex;
use reqwest::{header, Client, ClientBuilder, Request, RequestBuilder, Response, StatusCode};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use sha1::{Digest, Sha1};
use time::OffsetDateTime;
use tokio::sync::RwLock as AsyncRwLock;
@ -83,6 +86,13 @@ pub enum ClientType {
}
impl ClientType {
fn is_web(self) -> bool {
matches!(
self,
ClientType::Desktop | ClientType::DesktopMusic | ClientType::Mobile
)
}
fn needs_deobf(self) -> bool {
!matches!(self, ClientType::Ios)
}
@ -293,9 +303,9 @@ const YOUTUBEI_V1_URL: &str = "https://www.youtube.com/youtubei/v1/";
const YOUTUBEI_V1_GAPIS_URL: &str = "https://youtubei.googleapis.com/youtubei/v1/";
const YOUTUBE_MUSIC_V1_URL: &str = "https://music.youtube.com/youtubei/v1/";
const YOUTUBEI_MOBILE_V1_URL: &str = "https://m.youtube.com/youtubei/v1/";
const YOUTUBE_HOME_URL: &str = "https://www.youtube.com/";
const YOUTUBE_MUSIC_HOME_URL: &str = "https://music.youtube.com/";
const YOUTUBE_MOBILE_HOME_URL: &str = "https://m.youtube.com/";
const YOUTUBE_HOME_URL: &str = "https://www.youtube.com";
const YOUTUBE_MUSIC_HOME_URL: &str = "https://music.youtube.com";
const YOUTUBE_MOBILE_HOME_URL: &str = "https://m.youtube.com";
const YOUTUBE_TV_URL: &str = "https://www.youtube.com/tv";
const DISABLE_PRETTY_PRINT_PARAMETER: &str = "prettyPrint=false";
@ -479,6 +489,7 @@ struct CacheHolder {
clients: HashMap<ClientType, AsyncRwLock<CacheEntry<ClientData>>>,
deobf: AsyncRwLock<CacheEntry<DeobfData>>,
oauth_token: RwLock<Option<OauthToken>>,
auth_cookie: RwLock<Option<String>>,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
@ -489,6 +500,8 @@ struct CacheData {
deobf: CacheEntry<DeobfData>,
#[serde(skip_serializing_if = "Option::is_none")]
oauth_token: Option<OauthToken>,
#[serde(skip_serializing_if = "Option::is_none")]
auth_cookie: Option<String>,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
@ -652,6 +665,7 @@ impl RustyPipeBuilder {
clients: cache_clients,
deobf: AsyncRwLock::new(cdata.deobf),
oauth_token: RwLock::new(cdata.oauth_token),
auth_cookie: RwLock::new(cdata.auth_cookie),
},
default_opts: self.default_opts,
user_agent,
@ -797,13 +811,6 @@ impl RustyPipeBuilder {
self
}
/// Enable authentication for all requests
#[must_use]
pub fn authenticated(mut self) -> Self {
self.default_opts.auth = Some(true);
self
}
/// Disable authentication for all requests
#[must_use]
pub fn unauthenticated(mut self) -> Self {
@ -926,7 +933,15 @@ impl RustyPipe {
let status = res.status();
if status.is_client_error() || status.is_server_error() {
Err(Error::HttpStatus(status.into(), "none".into()))
let error_msg = if let Ok(body) = res.text().await {
serde_json::from_str::<response::ErrorResponse>(&body)
.map(|r| Cow::from(r.error.message))
.ok()
} else {
None
}
.unwrap_or_default();
Err(Error::HttpStatus(status.into(), error_msg))
} else {
Ok(res)
}
@ -1120,6 +1135,7 @@ impl RustyPipe {
clients: cache_clients,
deobf: self.inner.cache.deobf.read().await.clone(),
oauth_token: self.inner.cache.oauth_token.read().unwrap().clone(),
auth_cookie: self.inner.cache.auth_cookie.read().unwrap().clone(),
};
match serde_json::to_string(&cdata) {
@ -1417,6 +1433,22 @@ impl RustyPipe {
Err(Error::Auth(AuthError::NoLogin))
}
}
/// Set the user authentication cookie
///
/// The cookie is used for authenticated requests with browser-based clients
/// (Desktop, DesktopMusic, Mobile).
///
/// **Note:** YouTube rotates cookies every few minutes when using the web applications.
/// So you should not use the session you obtained cookies from afterwards or it will
/// become invalid.
///
/// I recommend to log in using Incognito mode, get the cookies from the devtools
/// and then close the page.
pub async fn set_auth_cookie<S: Into<String>>(&self, cookie: S) {
let mut c = self.inner.cache.auth_cookie.write().unwrap();
*c = Some(cookie.into());
}
}
impl RustyPipeQuery {
@ -1459,9 +1491,8 @@ impl RustyPipeQuery {
/// Enable authentication for this request
///
/// RustyPipe uses YouTube TV's OAuth authentication. This means that authentication
/// only works when using the TV client. Enabling authentication for other clients
/// results in a 400 error.
/// Depending on the client type RustyPipe uses either the authentication cookie or the
/// OAuth token to authenticate requests.
#[must_use]
pub fn authenticated(mut self) -> Self {
self.opts.auth = Some(true);
@ -1525,13 +1556,48 @@ impl RustyPipeQuery {
}
}
/// Return `true` if the client has stored login credentials and authentication has not been disabled
pub fn auth_enabled(&self) -> bool {
/// Return `true` if the client has stored login credentials for the given client type
/// and authentication has not been disabled
pub fn auth_enabled(&self, ctype: ClientType) -> bool {
if self.opts.auth == Some(false) {
return false;
}
let cache_token = self.client.inner.cache.oauth_token.read().unwrap();
cache_token.is_some()
if ctype.is_web() {
let auth_cookie = self.client.inner.cache.auth_cookie.read().unwrap();
auth_cookie.is_some()
} else if ctype == ClientType::Tv {
let cache_token = self.client.inner.cache.oauth_token.read().unwrap();
cache_token.is_some()
} else {
false
}
}
/// Return the first client type from the given list which has login credentials available.
///
/// Returns [`None`] if authentication has been disabled or there are no available client types.
pub fn auth_enabled_client(&self, clients: &[ClientType]) -> Option<ClientType> {
if self.opts.auth == Some(false) {
return None;
}
let (has_cookie, has_token) = {
let auth_cookie = self.client.inner.cache.auth_cookie.read().unwrap();
let oauth_token = self.client.inner.cache.oauth_token.read().unwrap();
(auth_cookie.is_some(), oauth_token.is_some())
};
clients
.iter()
.find(|c| {
if c.is_web() {
has_cookie
} else if **c == ClientType::Tv {
has_token
} else {
false
}
})
.copied()
}
/// Create a new context object, which is included in every request to
@ -1666,7 +1732,7 @@ impl RustyPipeQuery {
ctype: ClientType,
endpoint: &str,
visitor_data: Option<&str>,
) -> RequestBuilder {
) -> Result<RequestBuilder, Error> {
let mut r = match ctype {
ClientType::Desktop => self
.client
@ -1752,7 +1818,55 @@ impl RustyPipeQuery {
if let Some(vdata) = self.opts.visitor_data.as_deref().or(visitor_data) {
r = r.header("X-Goog-EOM-Visitor-Id", vdata);
}
r
let mut cookie = None;
if self.opts.auth == Some(true) {
if ctype.is_web() {
let auth_cookie = self
.client
.inner
.cache
.auth_cookie
.read()
.unwrap()
.clone()
.ok_or(Error::Auth(AuthError::NoLogin))?;
if let Some(auth_header) = Self::sapisidhash_header(&auth_cookie, ctype) {
r = r.header(header::AUTHORIZATION, auth_header);
}
cookie = Some(auth_cookie);
} else if ctype == ClientType::Tv {
let access_token = self.client.user_auth_access_token().await?;
r = r.header(header::AUTHORIZATION, format!("Bearer {}", access_token));
}
}
if ctype.is_web() {
r = r.header(header::COOKIE, cookie.as_deref().unwrap_or(CONSENT_COOKIE));
}
Ok(r)
}
fn sapisidhash_header(cookie: &str, ctype: ClientType) -> Option<String> {
let sapisid = cookie
.split(';')
.find_map(|c| c.trim().strip_prefix("SAPISID="))?;
let time_now = OffsetDateTime::now_utc().unix_timestamp();
let mut sapisidhash = Sha1::new();
sapisidhash.update(time_now.to_string());
sapisidhash.update(" ");
sapisidhash.update(sapisid);
sapisidhash.update(" ");
sapisidhash.update(match ctype {
ClientType::DesktopMusic => YOUTUBE_MUSIC_HOME_URL,
ClientType::Mobile => YOUTUBE_MOBILE_HOME_URL,
_ => YOUTUBE_HOME_URL,
});
let sapisidhash_hex = data_encoding::HEXLOWER.encode(&sapisidhash.finalize());
Some(format!("SAPISIDHASH {time_now}_{sapisidhash_hex}"))
}
/// Get a YouTube visitor data cookie, which is necessary for certain requests
@ -1892,17 +2006,14 @@ impl RustyPipeQuery {
visitor_data: Some(&visitor_data),
client_type: ctype,
artist: ctx_src.artist,
authenticated: self.opts.auth.unwrap_or_default(),
};
let mut r = self
let request = self
.request_builder(ctype, endpoint, ctx.visitor_data)
.await;
if self.opts.auth == Some(true) {
let access_token = self.client.user_auth_access_token().await?;
r = r.header(header::AUTHORIZATION, format!("Bearer {}", access_token));
}
let request = r.json(&req_body).build()?;
.await?
.json(&req_body)
.build()?;
let req_res = self.yt_request::<R, M>(&request, &ctx).await?;
@ -2031,7 +2142,7 @@ impl RustyPipeQuery {
let request = self
.request_builder(ctype, endpoint, None)
.await
.await?
.json(&req_body)
.build()?;
@ -2053,6 +2164,7 @@ struct MapRespCtx<'a> {
visitor_data: Option<&'a str>,
client_type: ClientType,
artist: Option<ArtistId>,
authenticated: bool,
}
/// Options to give to the mapper when making requests;
@ -2077,6 +2189,7 @@ impl<'a> MapRespCtx<'a> {
visitor_data: None,
client_type: ClientType::Desktop,
artist: None,
authenticated: false,
}
}
}

View file

@ -269,7 +269,14 @@ impl MapResponse<Paginator<TrackItem>> for response::MusicDetails {
.map(|c| c.next_continuation_data.continuation);
Ok(MapResult {
c: Paginator::new_ext(None, tracks, ctoken, None, ContinuationEndpoint::MusicNext),
c: Paginator::new_ext(
None,
tracks,
ctoken,
None,
ContinuationEndpoint::MusicNext,
false,
),
warnings,
})
}

182
src/client/music_history.rs Normal file
View file

@ -0,0 +1,182 @@
use crate::{
client::{
response::{self, music_item::MusicListMapper},
ClientType, MapResponse, QBrowseParams, RustyPipeQuery,
},
error::{Error, ExtractionError},
model::{
paginator::{ContinuationEndpoint, Paginator},
AlbumItem, ArtistItem, MusicPlaylistItem, TrackItem,
},
serializer::MapResult,
};
use super::MapRespCtx;
impl RustyPipeQuery {
/// Get a list of tracks from YouTube Music which the current user recently played
///
/// Requires authentication cookies.
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_history(&self) -> Result<Paginator<TrackItem>, Error> {
let request_body = QBrowseParams {
browse_id: "FEmusic_history",
params: "oggECgIIAQ%3D%3D",
};
self.clone()
.authenticated()
.execute_request::<response::MusicHistory, _, _>(
ClientType::DesktopMusic,
"music_history",
"",
"browse",
&request_body,
)
.await
}
/// Get a list of YouTube Music artists which the current user subscribed to
///
/// Requires authentication cookies.
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_saved_artists(&self) -> Result<Paginator<ArtistItem>, Error> {
self.clone()
.authenticated()
.continuation(
"4qmFsgIyEh5GRW11c2ljX2xpYnJhcnlfY29ycHVzX2FydGlzdHMaEGdnTUdLZ1FJQUJBQm9BWUI%3D",
ContinuationEndpoint::MusicBrowse,
None,
)
.await
}
/// Get a list of YouTube Music albums which the current user has added to their collection
///
/// Requires authentication cookies.
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_saved_albums(&self) -> Result<Paginator<AlbumItem>, Error> {
self.clone()
.authenticated()
.continuation(
"4qmFsgIoEhRGRW11c2ljX2xpa2VkX2FsYnVtcxoQZ2dNR0tnUUlBQkFCb0FZQg%3D%3D",
ContinuationEndpoint::MusicBrowse,
None,
)
.await
}
/// Get a list of YouTube Music tracks which the current user has added to their collection
///
/// Contains both liked tracks and tracks from saved albums.
///
/// Requires authentication cookies.
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_saved_tracks(&self) -> Result<Paginator<TrackItem>, Error> {
self.clone()
.authenticated()
.continuation(
"4qmFsgIoEhRGRW11c2ljX2xpa2VkX3ZpZGVvcxoQZ2dNR0tnUUlBQkFCb0FZQg%3D%3D",
ContinuationEndpoint::MusicBrowse,
None,
)
.await
}
/// Get a list of YouTube Music playlists which the current user has added to their collection
///
/// Requires authentication cookies.
#[tracing::instrument(skip(self), level = "error")]
pub async fn music_saved_playlists(&self) -> Result<Paginator<MusicPlaylistItem>, Error> {
self.clone()
.authenticated()
.continuation(
"4qmFsgIrEhdGRW11c2ljX2xpa2VkX3BsYXlsaXN0cxoQZ2dNR0tnUUlBQkFCb0FZQg%3D%3D",
ContinuationEndpoint::MusicBrowse,
None,
)
.await
}
}
impl MapResponse<Paginator<TrackItem>> for response::MusicHistory {
fn map_response(
self,
ctx: &MapRespCtx<'_>,
) -> Result<MapResult<Paginator<TrackItem>>, ExtractionError> {
let contents = match self.contents {
response::music_playlist::Contents::SingleColumnBrowseResultsRenderer(c) => {
c.contents
.into_iter()
.next()
.ok_or(ExtractionError::InvalidData("no content".into()))?
.tab_renderer
.content
.section_list_renderer
}
response::music_playlist::Contents::TwoColumnBrowseResultsRenderer {
secondary_contents,
..
} => secondary_contents.section_list_renderer,
};
let mut mapper = MusicListMapper::new(ctx.lang);
for shelf in contents.contents {
let shelf = if let response::music_item::ItemSection::MusicShelfRenderer(s) = shelf {
s
} else {
continue;
};
mapper.map_response(shelf.contents);
}
let map_res = mapper.conv_items();
let ctoken = contents
.continuations
.into_iter()
.next()
.map(|c| c.next_continuation_data.continuation);
Ok(MapResult {
c: Paginator::new_ext(
None,
map_res.c,
ctoken,
None,
ContinuationEndpoint::MusicBrowse,
true,
),
warnings: map_res.warnings,
})
}
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::BufReader};
use path_macro::path;
use crate::util::tests::TESTFILES;
use super::*;
#[test]
fn map_history() {
let json_path = path!(*TESTFILES / "music_history" / "music_history.json");
let json_file = File::open(json_path).unwrap();
let history: response::MusicHistory =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res = history.map_response(&MapRespCtx::test("")).unwrap();
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(map_res.c);
}
}

View file

@ -122,6 +122,20 @@ impl RustyPipeQuery {
}
Ok(album)
}
/// Get all liked YouTube Music tracks of the logged-in user
///
/// The difference to [`RustyPipeQuery::music_saved_tracks`] is that this function only returns
/// tracks that were explicitly liked by the user.
///
/// Requires authentication cookies.
pub async fn music_liked_tracks(&self) -> Result<MusicPlaylist, Error> {
self.clone()
.authenticated()
.music_playlist("LM")
.await
.map_err(util::map_internal_playlist_err)
}
}
impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
@ -298,6 +312,7 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
ctoken,
ctx.visitor_data.map(str::to_owned),
ContinuationEndpoint::MusicBrowse,
ctx.authenticated,
),
related_playlists: Paginator::new_ext(
None,
@ -305,6 +320,7 @@ impl MapResponse<MusicPlaylist> for response::MusicPlaylist {
related_ctoken,
ctx.visitor_data.map(str::to_owned),
ContinuationEndpoint::MusicBrowse,
ctx.authenticated,
),
},
warnings: map_res.warnings,

View file

@ -199,6 +199,7 @@ impl<T: FromYtItem> MapResponse<MusicSearchResult<T>> for response::MusicSearch
ctoken,
ctx.visitor_data.map(str::to_owned),
ContinuationEndpoint::MusicSearch,
false,
),
corrected_query,
},

View file

@ -77,6 +77,7 @@ fn map_yt_paginator<T: FromYtItem>(
ctoken: p.ctoken,
visitor_data: p.visitor_data,
endpoint,
authenticated: p.authenticated,
}
}
@ -90,6 +91,7 @@ fn map_ytm_paginator<T: FromYtItem>(
ctoken: p.ctoken,
visitor_data: p.visitor_data,
endpoint,
authenticated: p.authenticated,
}
}
@ -120,7 +122,14 @@ impl MapResponse<Paginator<YouTubeItem>> for response::Continuation {
mapper.map_response(items);
Ok(MapResult {
c: Paginator::new(self.estimated_results, mapper.items, mapper.ctoken),
c: Paginator::new_ext(
self.estimated_results,
mapper.items,
mapper.ctoken,
None,
ContinuationEndpoint::Browse,
ctx.authenticated,
),
warnings: mapper.warnings,
})
}
@ -188,7 +197,14 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
.map(|cont| cont.next_continuation_data.continuation);
Ok(MapResult {
c: Paginator::new(None, map_res.c, ctoken),
c: Paginator::new_ext(
None,
map_res.c,
ctoken,
None,
ContinuationEndpoint::MusicBrowse,
ctx.authenticated,
),
warnings: map_res.warnings,
})
}
@ -197,13 +213,24 @@ impl MapResponse<Paginator<MusicItem>> for response::MusicContinuation {
impl<T: FromYtItem> Paginator<T> {
/// Get the next page from the paginator (or `None` if the paginator is exhausted)
pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> {
// let mut q = query.as_ref().clone();
// if self.authenticated {
// q = q.authenticated();
// }
Ok(match &self.ctoken {
Some(ctoken) => Some(
query
.as_ref()
.continuation(ctoken, self.endpoint, self.visitor_data.as_deref())
.await?,
),
Some(ctoken) => {
let q = if self.authenticated {
&query.as_ref().clone().authenticated()
} else {
query.as_ref()
};
Some(
q.continuation(ctoken, self.endpoint, self.visitor_data.as_deref())
.await?,
)
}
_ => None,
})
}
@ -383,7 +410,10 @@ mod tests {
use super::*;
use crate::{
model::{MusicPlaylistItem, PlaylistItem, TrackItem, VideoItem},
model::{
AlbumItem, ArtistItem, ChannelItem, MusicPlaylistItem, PlaylistItem, TrackItem,
VideoItem,
},
util::tests::TESTFILES,
};
@ -454,10 +484,32 @@ mod tests {
insta::assert_ron_snapshot!(format!("map_{name}"), paginator);
}
#[rstest]
#[case::subscriptions("subscriptions", path!("history" / "subscriptions.json"))]
fn map_continuation_channels(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();
let items: response::Continuation =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<YouTubeItem>> =
items.map_response(&MapRespCtx::test("")).unwrap();
let paginator: Paginator<ChannelItem> =
map_yt_paginator(map_res.c, ContinuationEndpoint::Browse);
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(format!("map_{name}"), paginator);
}
#[rstest]
#[case::playlist_tracks("playlist_tracks", path!("music_playlist" / "playlist_cont.json"))]
#[case::search_tracks("search_tracks", path!("music_search" / "tracks_cont.json"))]
#[case::radio_tracks("radio_tracks", path!("music_details" / "radio_cont.json"))]
#[case::saved_tracks("saved_tracks", path!("music_history" / "saved_tracks.json"))]
fn map_continuation_tracks(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();
@ -477,8 +529,51 @@ mod tests {
insta::assert_ron_snapshot!(format!("map_{name}"), paginator);
}
#[rstest]
#[case::saved_artists("saved_artists", path!("music_history" / "saved_artists.json"))]
fn map_continuation_artists(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();
let items: response::MusicContinuation =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<MusicItem>> =
items.map_response(&MapRespCtx::test("")).unwrap();
let paginator: Paginator<ArtistItem> =
map_ytm_paginator(map_res.c, ContinuationEndpoint::MusicBrowse);
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(format!("map_{name}"), paginator);
}
#[rstest]
#[case::saved_albums("saved_albums", path!("music_history" / "saved_albums.json"))]
fn map_continuation_albums(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();
let items: response::MusicContinuation =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<MusicItem>> =
items.map_response(&MapRespCtx::test("")).unwrap();
let paginator: Paginator<AlbumItem> =
map_ytm_paginator(map_res.c, ContinuationEndpoint::MusicBrowse);
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(format!("map_{name}"), paginator);
}
#[rstest]
#[case::playlist_related("playlist_related", path!("music_playlist" / "playlist_related.json"))]
#[case::saved_playlists("saved_playlists", path!("music_history" / "saved_playlists.json"))]
fn map_continuation_music_playlists(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();

View file

@ -84,24 +84,28 @@ impl RustyPipeQuery {
match res {
Ok(res) => return Ok(res),
Err(Error::Extraction(e)) => {
if e.use_login() && self.auth_enabled() {
tracing::info!("{e}; fetching player with login");
if e.use_login() {
if let Some(c) = self.auth_enabled_client(clients) {
tracing::info!("{e}; fetching player with login");
match self
.clone()
.authenticated()
.player_from_client(video_id, ClientType::Tv)
.await
{
Ok(res) => return Ok(res),
Err(Error::Extraction(e)) => {
if !e.switch_client() {
return Err(Error::Extraction(e));
match self
.clone()
.authenticated()
.player_from_client(video_id, c)
.await
{
Ok(res) => return Ok(res),
Err(Error::Extraction(e)) => {
if !e.switch_client() {
return Err(Error::Extraction(e));
}
}
Err(e) => return Err(e),
}
Err(e) => return Err(e),
last_e = Error::Extraction(e);
} else {
return Err(Error::Extraction(e));
}
last_e = Error::Extraction(e);
} else if !e.switch_client() {
return Err(Error::Extraction(e));
}
@ -759,6 +763,7 @@ mod tests {
visitor_data: None,
client_type,
artist: None,
authenticated: false,
})
.unwrap();

View file

@ -33,6 +33,28 @@ impl RustyPipeQuery {
)
.await
}
/// Get all liked videos of the logged-in user
///
/// Requires authentication cookies.
pub async fn liked_videos(&self) -> Result<Playlist, Error> {
self.clone()
.authenticated()
.playlist("LL")
.await
.map_err(util::map_internal_playlist_err)
}
/// Get the "Watch later" playlist of the logged-in user
///
/// Requires authentication cookies.
pub async fn watch_later(&self) -> Result<Playlist, Error> {
self.clone()
.authenticated()
.playlist("WL")
.await
.map_err(util::map_internal_playlist_err)
}
}
impl MapResponse<Playlist> for response::Playlist {
@ -217,6 +239,7 @@ impl MapResponse<Playlist> for response::Playlist {
mapper.ctoken,
ctx.visitor_data.map(str::to_owned),
ContinuationEndpoint::Browse,
ctx.authenticated,
),
video_count: n_videos,
thumbnail: thumbnails.into(),

View file

@ -0,0 +1,8 @@
use serde::Deserialize;
use super::{video_item::YouTubeListRendererWrap, Tab, TwoColumnBrowseResults};
#[derive(Debug, Deserialize)]
pub(crate) struct History {
pub contents: TwoColumnBrowseResults<Tab<YouTubeListRendererWrap>>,
}

View file

@ -1,8 +1,10 @@
pub(crate) mod channel;
pub(crate) mod history;
pub(crate) mod music_artist;
pub(crate) mod music_charts;
pub(crate) mod music_details;
pub(crate) mod music_genres;
pub(crate) mod music_history;
pub(crate) mod music_item;
pub(crate) mod music_new;
pub(crate) mod music_playlist;
@ -17,6 +19,7 @@ pub(crate) mod video_item;
pub(crate) use channel::Channel;
pub(crate) use channel::ChannelAbout;
pub(crate) use history::History;
pub(crate) use music_artist::MusicArtist;
pub(crate) use music_artist::MusicArtistAlbums;
pub(crate) use music_charts::MusicCharts;
@ -25,6 +28,7 @@ pub(crate) use music_details::MusicLyrics;
pub(crate) use music_details::MusicRelated;
pub(crate) use music_genres::MusicGenre;
pub(crate) use music_genres::MusicGenres;
pub(crate) use music_history::MusicHistory;
pub(crate) use music_item::MusicContinuation;
pub(crate) use music_new::MusicNew;
pub(crate) use music_playlist::MusicPlaylist;
@ -668,3 +672,6 @@ pub(crate) struct ThumbnailOverlayBadgeViewModel {
pub(crate) struct ThumbnailBadges {
pub thumbnail_badge_view_model: TextBox,
}
#[derive(Debug, Deserialize)]
pub(crate) struct Empty {}

View file

@ -0,0 +1,8 @@
use serde::Deserialize;
use super::music_playlist::Contents;
#[derive(Debug, Deserialize)]
pub(crate) struct MusicHistory {
pub contents: Contents,
}

View file

@ -272,7 +272,7 @@ pub(crate) struct QueueMusicItem {
#[derive(Default, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MusicThumbnailRenderer {
#[serde(alias = "croppedSquareThumbnailRenderer")]
#[serde(default, alias = "croppedSquareThumbnailRenderer")]
pub music_thumbnail_renderer: ThumbnailsWrap,
}
@ -767,8 +767,16 @@ impl MusicListMapper {
}
// Artist / Album / Playlist
Some((page_type, id)) => {
// Ignore "Shuffle all" button and builtin "Liked music" and "Saved episodes" playlists
if page_type == MusicPageType::None
|| (page_type == (MusicPageType::Playlist { is_podcast: false })
&& matches!(id.as_str(), "MLCT" | "LM" | "SE"))
{
return Ok(None);
}
let mut subtitle_parts = c2
.ok_or_else(|| "could not get subtitle".to_owned())?
.ok_or_else(|| format!("{id}: could not get subtitle"))?
.renderer
.text
.split(util::DOT_SEPARATOR)

View file

@ -4,7 +4,7 @@ use serde::Deserialize;
use serde_with::serde_as;
use serde_with::{DefaultOnError, DisplayFromStr};
use super::{ResponseContext, Thumbnails};
use super::{Empty, ResponseContext, Thumbnails};
use crate::serializer::{text::Text, MapResult};
#[serde_as]
@ -57,9 +57,6 @@ pub(crate) enum PlayabilityStatus {
},
}
#[derive(Debug, Deserialize)]
pub(crate) struct Empty {}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ErrorScreen {

View file

@ -6,6 +6,8 @@ use crate::{
util,
};
use super::Empty;
/// navigation/resolve_url response model
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -35,6 +37,9 @@ pub(crate) enum NavigationEndpoint {
WatchPlaylist {
watch_playlist_endpoint: WatchPlaylistEndpoint,
},
#[serde(rename_all = "camelCase")]
#[allow(unused)]
CreatePlaylist { create_playlist_endpoint: Empty },
}
#[derive(Debug, Deserialize)]
@ -338,6 +343,10 @@ impl NavigationEndpoint {
id: watch_playlist_endpoint.playlist_id,
typ: MusicPageType::Playlist { is_podcast: false },
}),
NavigationEndpoint::CreatePlaylist { .. } => Some(MusicPage {
id: String::new(),
typ: MusicPageType::None,
}),
}
}
@ -381,6 +390,7 @@ impl NavigationEndpoint {
NavigationEndpoint::WatchPlaylist {
watch_playlist_endpoint,
} => Some(watch_playlist_endpoint.playlist_id),
NavigationEndpoint::CreatePlaylist { .. } => None,
}
}
}

View file

@ -122,6 +122,7 @@ impl<T: FromYtItem> MapResponse<SearchResult<T>> for response::Search {
mapper.ctoken,
None,
ContinuationEndpoint::Search,
false,
),
corrected_query: mapper.corrected_query,
visitor_data: self

View file

@ -0,0 +1,247 @@
---
source: src/client/history.rs
expression: map_res.c
---
Paginator(
count: None,
items: [
VideoItem(
id: "mPshy_DWxfo",
name: "trying TWEENING everything! (FAILED) PLEASE GIVE ME SOME ADVICEEE",
duration: Some(6),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFACKgBEF5IWvKriqkDMwgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAHwAQH4Af4JgALOBYoCDAgAEAEYfyAyKEAwDw==&rs=AOn4CLBfBVk2IGdGGGmpqOir2RbC8cY1xw",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFACMQBEG5IWvKriqkDMwgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAHwAQH4Af4JgALOBYoCDAgAEAEYfyAyKEAwDw==&rs=AOn4CLDnRYKBX4qMlA54i-q3W7w1WvGApg",
width: 196,
height: 110,
),
Thumbnail(
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFBCPYBEIoBSFryq4qpAzMIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB8AEB-AH-CYACzgWKAgwIABABGH8gMihAMA8=&rs=AOn4CLDza_6r3345q6SBZvGm292mOobNPg",
width: 246,
height: 138,
),
Thumbnail(
url: "https://i.ytimg.com/vi/mPshy_DWxfo/hqdefault.jpg?sqp=-oaymwFBCNACELwBSFryq4qpAzMIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB8AEB-AH-CYACzgWKAgwIABABGH8gMihAMA8=&rs=AOn4CLDySwxxAy2hfw2YcAKs6ERLhzPTkQ",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCM7OXM6t80a3e3tzQDWxwEA",
name: "Ari",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/TpeTKFR6QWu4Cjam4PcpQwCPMnammWnSg93CdBvgFFLhkGm4nbQkUFKaAIYJ1ChUy9IgmJIQMRg=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: none,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: None,
view_count: Some(15),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
VideoItem(
id: "SRWatgS077k",
name: "My Time at \"Camp Operetta\"",
duration: Some(578),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAN8mzi3fbrJgqJiEeqpMZXRa7AuQ",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLBgxonLRY-4QQ1-jR3Xen-fAZcHHQ",
width: 196,
height: 110,
),
Thumbnail(
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBOk1abznwO5Bm0_m5YXMFkU0JSog",
width: 246,
height: 138,
),
Thumbnail(
url: "https://i.ytimg.com/vi/SRWatgS077k/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLCQt7cAJuE-W8t1TnQnSe5EVbsw8A",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCGwu0nbY2wSkW8N-cghnLpA",
name: "JaidenAnimations",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/gopbHeiDtEB932rIFqLlR4D_hFtd-BcdGrQgGeyDpkD3guskkbT74DsJYPGo3x7MqkyqtgL-=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: None,
view_count: Some(23907328),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: Some("What can I say other than that was one heck of a time\n\nScribble Showdown Tickets: https://www.scribbleshowdown.com/\n\n\n♥ The Team ♥\nDenny: https://www.instagram.com/90percentknuckles/\nAtrox:..."),
),
VideoItem(
id: "kTxlkDoqArA",
name: "Wie Cartoons Früher gemacht wurden!",
duration: Some(283),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLBYdDA06ekKDlhB0PSlTwf6Ih1cMg",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAgu_Ad1pFCsa3jINV1ocaVOQWOXg",
width: 196,
height: 110,
),
Thumbnail(
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLDkOVQbyZlrZ_jbdkSzUd5RiobObA",
width: 246,
height: 138,
),
Thumbnail(
url: "https://i.ytimg.com/vi/kTxlkDoqArA/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLA5cnUH03I2lg1-FOJ01njh8UOJEw",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCxTHCMaxURhapisCMBv8y0A",
name: "Plankton",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/Cdlsy3IXgis5hNYRwvohPB9AIxH8tNdEo9CwxXK1i3QEUO7YN3p4YJ_cd5ruGsmNhvoX7803=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: None,
view_count: Some(390010),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: Some("Folgt mir auf Instagram!\nhttps://instagram.com/plankton.gif \n\nÜBER DEN KANAL:\nRede viel wenn der Tag lang ist"),
),
VideoItem(
id: "oIVSKQ8NMqk",
name: "What I learned on highschool swim",
duration: Some(620),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAeTbOz6FlrH1x3jA4AwYcTGmUwxg",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAEzpr1xBI-8jJwZz72NHj9VKyefA",
width: 196,
height: 110,
),
Thumbnail(
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBNzC8nvKtO7fmqzavWemou7QOLOg",
width: 246,
height: 138,
),
Thumbnail(
url: "https://i.ytimg.com/vi/oIVSKQ8NMqk/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLA1_VgkVeq4ELmrQ8a4vhtJhg6TMA",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UCsKVP_4zQ877TEiH_Ih5yDQ",
name: "illymation",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/ytc/AIdro_n3doafn2qRRawkYet_KQdH2Jl1ugSQnjnd0Ham12C9MYI=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: None,
view_count: Some(6491367),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: Some("okay, so I wasn\'t the BEST ... but I tried my best!\n▶ Black Friday Merch sale: https://www.hereforthechaos.com\n▶ SNEAK PEEKS ON PATREON! http://patreon.com/illymation\n\n▶ BG ARTIST: Ingrid..."),
),
VideoItem(
id: "X30eFeqrHJo",
name: "My Last Week of University!",
duration: Some(659),
thumbnail: [
Thumbnail(
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEmCKgBEF5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAc6-vKGjlwODu5rDSHK2tz4sRzYQ",
width: 168,
height: 94,
),
Thumbnail(
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEmCMQBEG5IWvKriqkDGQgBFQAAiEIYAdgBAeIBCggYEAIYBjgBQAE=&rs=AOn4CLAE7jwC0MudkKK-I1nyGvCheljvtQ",
width: 196,
height: 110,
),
Thumbnail(
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEnCPYBEIoBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLAfIZ4htcNHP3RWahpR4XM7U-UTFQ",
width: 246,
height: 138,
),
Thumbnail(
url: "https://i.ytimg.com/vi/X30eFeqrHJo/hqdefault.jpg?sqp=-oaymwEnCNACELwBSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLDYMLObTeyxHK_PewK4Rwk3V-2KGw",
width: 336,
height: 188,
),
],
channel: Some(ChannelTag(
id: "UC6a8lp6vaCMhUVXPyynhjUA",
name: "Ruby Granger",
avatar: [
Thumbnail(
url: "https://yt3.ggpht.com/u9qrR3ceVkt7yen48Rd1WWV_w-OdE5iejCNI2y-PyG0tpd7xlqWFDahsaZa02cMk7O-0WkCL=s68-c-k-c0x00ffffff-no-rj",
width: 68,
height: 68,
),
],
verification: verified,
subscriber_count: None,
)),
publish_date: "[date]",
publish_date_txt: None,
view_count: Some(132844),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: Some("This first year has been somewhat hectic, and certainly was tricky in the first semester; however, I have enjoyed it immensely and, as I said, am sad that this term has now ended. I am returning..."),
),
],
ctoken: Some("4qmFsgJMEglGRWhpc3RvcnkaKENBSjZHbmx5WVRWb2QyOU9RMmR6U1RSaGNXMTFkMWxSTms4M05WcFKaAhRicm93c2UtZmVlZEZFaGlzdG9yeQ%3D%3D"),
endpoint: browse,
authenticated: true,
)

View file

@ -0,0 +1,748 @@
---
source: src/client/music_history.rs
expression: map_res.c
---
Paginator(
count: Some(23),
items: [
TrackItem(
id: "-gBtW4GhF3Y",
name: "O Du fröhliche",
duration: Some(214),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/xH15w12BHaphTUcf1ivgy4Q6sZh1m3ZSklFqL6O9H5hixdtpzHHEDF48uSy3VDJJjaqf-SQurQmcPnhaCw=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/xH15w12BHaphTUcf1ivgy4Q6sZh1m3ZSklFqL6O9H5hixdtpzHHEDF48uSy3VDJJjaqf-SQurQmcPnhaCw=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCE7_p3lcXA-YXRZp2PjrgYw"),
name: "Helene Fischer",
),
],
artist_id: Some("UCE7_p3lcXA-YXRZp2PjrgYw"),
album: Some(AlbumId(
id: "MPREb_IBxM8XVyrqh",
name: "Weihnachten",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "nsV9bCW3sLM",
name: "Stille Nacht",
duration: Some(264),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/xH15w12BHaphTUcf1ivgy4Q6sZh1m3ZSklFqL6O9H5hixdtpzHHEDF48uSy3VDJJjaqf-SQurQmcPnhaCw=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/xH15w12BHaphTUcf1ivgy4Q6sZh1m3ZSklFqL6O9H5hixdtpzHHEDF48uSy3VDJJjaqf-SQurQmcPnhaCw=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCE7_p3lcXA-YXRZp2PjrgYw"),
name: "Helene Fischer",
),
],
artist_id: Some("UCE7_p3lcXA-YXRZp2PjrgYw"),
album: Some(AlbumId(
id: "MPREb_IBxM8XVyrqh",
name: "Weihnachten",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "3-oqGxnJTrA",
name: "Ihr Kinderlein kommet",
duration: Some(195),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/xH15w12BHaphTUcf1ivgy4Q6sZh1m3ZSklFqL6O9H5hixdtpzHHEDF48uSy3VDJJjaqf-SQurQmcPnhaCw=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/xH15w12BHaphTUcf1ivgy4Q6sZh1m3ZSklFqL6O9H5hixdtpzHHEDF48uSy3VDJJjaqf-SQurQmcPnhaCw=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCE7_p3lcXA-YXRZp2PjrgYw"),
name: "Helene Fischer",
),
],
artist_id: Some("UCE7_p3lcXA-YXRZp2PjrgYw"),
album: Some(AlbumId(
id: "MPREb_IBxM8XVyrqh",
name: "Weihnachten",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "xBby89eXe1g",
name: "Tochter Zion",
duration: Some(186),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/xH15w12BHaphTUcf1ivgy4Q6sZh1m3ZSklFqL6O9H5hixdtpzHHEDF48uSy3VDJJjaqf-SQurQmcPnhaCw=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/xH15w12BHaphTUcf1ivgy4Q6sZh1m3ZSklFqL6O9H5hixdtpzHHEDF48uSy3VDJJjaqf-SQurQmcPnhaCw=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCE7_p3lcXA-YXRZp2PjrgYw"),
name: "Helene Fischer",
),
],
artist_id: Some("UCE7_p3lcXA-YXRZp2PjrgYw"),
album: Some(AlbumId(
id: "MPREb_IBxM8XVyrqh",
name: "Weihnachten",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "ikyIeWgP6i4",
name: "Adeste Fideles",
duration: Some(236),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/xH15w12BHaphTUcf1ivgy4Q6sZh1m3ZSklFqL6O9H5hixdtpzHHEDF48uSy3VDJJjaqf-SQurQmcPnhaCw=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/xH15w12BHaphTUcf1ivgy4Q6sZh1m3ZSklFqL6O9H5hixdtpzHHEDF48uSy3VDJJjaqf-SQurQmcPnhaCw=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCE7_p3lcXA-YXRZp2PjrgYw"),
name: "Helene Fischer",
),
],
artist_id: Some("UCE7_p3lcXA-YXRZp2PjrgYw"),
album: Some(AlbumId(
id: "MPREb_IBxM8XVyrqh",
name: "Weihnachten",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "u54XYn1nCZ8",
name: "Das Polizeiboot",
duration: Some(187),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/-IzX3AN3btJwzM7YzUtDRu8-40B_qNcQlckN26aHVFNopjA4wiRGLuDfiTPrSx8X-ULA-GdkcbGU57M=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/-IzX3AN3btJwzM7YzUtDRu8-40B_qNcQlckN26aHVFNopjA4wiRGLuDfiTPrSx8X-ULA-GdkcbGU57M=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UC_WzOax81EduoCiIrWQCrTw"),
name: "SpongeBob Schwammkopf",
),
],
artist_id: Some("UC_WzOax81EduoCiIrWQCrTw"),
album: Some(AlbumId(
id: "MPREb_M2trHaS2Z39",
name: "Quallendisco",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "acOEjiOH2v8",
name: "We Made It",
duration: Some(212),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/kpuoWhUjjFS0CR_Bz7OY4JSHXIYzbYTa9FWalcXudTAETr1EioLtSa5ua5vNcla0_aAbVjUe0zv-OQxWsw=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/kpuoWhUjjFS0CR_Bz7OY4JSHXIYzbYTa9FWalcXudTAETr1EioLtSa5ua5vNcla0_aAbVjUe0zv-OQxWsw=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCi3H2bHgaTFwrfwx_GOJyZw"),
name: "t-low",
),
ArtistId(
id: Some("UCrWB2JlLx3-q8CUUiVXgedg"),
name: "Miksu / Macloud",
),
],
artist_id: Some("UCi3H2bHgaTFwrfwx_GOJyZw"),
album: Some(AlbumId(
id: "MPREb_fkur1VEwyKR",
name: "Percocet Party",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "Xg5dn6o-mME",
name: "Misfit Toys",
duration: Some(190),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/Xg5dn6o-mME/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3n1aZ8j7_o-eQbVtcbLCm2KSK2I6A",
width: 400,
height: 225,
),
],
artists: [
ArtistId(
id: Some("UCr4IKNkUPPmkwE_LAjtho0g"),
name: "Pusha T",
),
],
artist_id: Some("UCr4IKNkUPPmkwE_LAjtho0g"),
album: None,
view_count: None,
track_type: episode,
track_nr: None,
by_va: false,
),
TrackItem(
id: "Smy4qcyPMEc",
name: "Remember Me (Intro)",
duration: Some(101),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/AYcl_X1V-eF8utaaVUlgmY-ibcQwE2BsY0RW6TdbZ5qAK8UNUfA5xaNiERyCHv2PpsXNh_L3hPZkmdM=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/AYcl_X1V-eF8utaaVUlgmY-ibcQwE2BsY0RW6TdbZ5qAK8UNUfA5xaNiERyCHv2PpsXNh_L3hPZkmdM=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCGr1UQ4CwzRMmYoQfHQQWTg"),
name: "d4vd",
),
],
artist_id: Some("UCGr1UQ4CwzRMmYoQfHQQWTg"),
album: Some(AlbumId(
id: "MPREb_muqZ7sOFHBp",
name: "Arcane League of Legends: Season 2 (Soundtrack from the Animated Series)",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "cmEIneYW2yk",
name: "Paint The Town Blue (from the series Arcane League of Legends)",
duration: Some(115),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/AYcl_X1V-eF8utaaVUlgmY-ibcQwE2BsY0RW6TdbZ5qAK8UNUfA5xaNiERyCHv2PpsXNh_L3hPZkmdM=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/AYcl_X1V-eF8utaaVUlgmY-ibcQwE2BsY0RW6TdbZ5qAK8UNUfA5xaNiERyCHv2PpsXNh_L3hPZkmdM=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCn3fPGV_gVYAmpgb1APyQug"),
name: "Ashnikko",
),
],
artist_id: Some("UCn3fPGV_gVYAmpgb1APyQug"),
album: Some(AlbumId(
id: "MPREb_muqZ7sOFHBp",
name: "Arcane League of Legends: Season 2 (Soundtrack from the Animated Series)",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "HFVM4QE1qBA",
name: "To Ashes and Blood (from the series Arcane League of Legends)",
duration: Some(246),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/AYcl_X1V-eF8utaaVUlgmY-ibcQwE2BsY0RW6TdbZ5qAK8UNUfA5xaNiERyCHv2PpsXNh_L3hPZkmdM=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/AYcl_X1V-eF8utaaVUlgmY-ibcQwE2BsY0RW6TdbZ5qAK8UNUfA5xaNiERyCHv2PpsXNh_L3hPZkmdM=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCqishLHg7u5voH0sEmR-l6Q"),
name: "Woodkid",
),
],
artist_id: Some("UCqishLHg7u5voH0sEmR-l6Q"),
album: Some(AlbumId(
id: "MPREb_muqZ7sOFHBp",
name: "Arcane League of Legends: Season 2 (Soundtrack from the Animated Series)",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "O0qHqHt3JiY",
name: "Hellfire (from the series Arcane League of Legends)",
duration: Some(165),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/AYcl_X1V-eF8utaaVUlgmY-ibcQwE2BsY0RW6TdbZ5qAK8UNUfA5xaNiERyCHv2PpsXNh_L3hPZkmdM=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/AYcl_X1V-eF8utaaVUlgmY-ibcQwE2BsY0RW6TdbZ5qAK8UNUfA5xaNiERyCHv2PpsXNh_L3hPZkmdM=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCqRcnDXGwIt_mCXNuzKVqqg"),
name: "Fever 333",
),
],
artist_id: Some("UCqRcnDXGwIt_mCXNuzKVqqg"),
album: Some(AlbumId(
id: "MPREb_muqZ7sOFHBp",
name: "Arcane League of Legends: Season 2 (Soundtrack from the Animated Series)",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "rfDBTQNdj-M",
name: "Renegade (We Never Run) (from the series Arcane League of Legends) (feat. Jarina De Marco)",
duration: Some(162),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/AYcl_X1V-eF8utaaVUlgmY-ibcQwE2BsY0RW6TdbZ5qAK8UNUfA5xaNiERyCHv2PpsXNh_L3hPZkmdM=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/AYcl_X1V-eF8utaaVUlgmY-ibcQwE2BsY0RW6TdbZ5qAK8UNUfA5xaNiERyCHv2PpsXNh_L3hPZkmdM=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCm9GDfNgJCv-FJkWTIaiFIg"),
name: "Raja Kumari",
),
ArtistId(
id: Some("UCfW0_9uspt55KDtOZNPcSFg"),
name: "Stefflon Don",
),
],
artist_id: Some("UCm9GDfNgJCv-FJkWTIaiFIg"),
album: Some(AlbumId(
id: "MPREb_muqZ7sOFHBp",
name: "Arcane League of Legends: Season 2 (Soundtrack from the Animated Series)",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "_M409k9cOcg",
name: "특 S-Class",
duration: Some(196),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/MiN4KEyFzPNKECd0md-d4FtMpzbpVChSp_lWmh4w14CTfcLix05BOgS3TD5nQlrllMvp2_6T_e3lIJaD=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/MiN4KEyFzPNKECd0md-d4FtMpzbpVChSp_lWmh4w14CTfcLix05BOgS3TD5nQlrllMvp2_6T_e3lIJaD=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCIMmuidNJdncfMEelOU08Fg"),
name: "Stray Kids",
),
],
artist_id: Some("UCIMmuidNJdncfMEelOU08Fg"),
album: Some(AlbumId(
id: "MPREb_zR25p24PqIC",
name: "5-STAR",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "MpG_ft84IoE",
name: "Sucker (from the series Arcane League of Legends)",
duration: Some(225),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/AYcl_X1V-eF8utaaVUlgmY-ibcQwE2BsY0RW6TdbZ5qAK8UNUfA5xaNiERyCHv2PpsXNh_L3hPZkmdM=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/AYcl_X1V-eF8utaaVUlgmY-ibcQwE2BsY0RW6TdbZ5qAK8UNUfA5xaNiERyCHv2PpsXNh_L3hPZkmdM=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UC5xpuW4UI520aDhKXGaJYgQ"),
name: "Marcus King",
),
],
artist_id: Some("UC5xpuW4UI520aDhKXGaJYgQ"),
album: Some(AlbumId(
id: "MPREb_muqZ7sOFHBp",
name: "Arcane League of Legends: Season 2 (Soundtrack from the Animated Series)",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "g7W7MisCKWk",
name: "I Can\'t Hear It Now (from the series Arcane League of Legends)",
duration: Some(162),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/AYcl_X1V-eF8utaaVUlgmY-ibcQwE2BsY0RW6TdbZ5qAK8UNUfA5xaNiERyCHv2PpsXNh_L3hPZkmdM=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/AYcl_X1V-eF8utaaVUlgmY-ibcQwE2BsY0RW6TdbZ5qAK8UNUfA5xaNiERyCHv2PpsXNh_L3hPZkmdM=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCw5G4AVjJ_YI9BOTjj-v1iw"),
name: "Freya Ridings",
),
],
artist_id: Some("UCw5G4AVjJ_YI9BOTjj-v1iw"),
album: Some(AlbumId(
id: "MPREb_muqZ7sOFHBp",
name: "Arcane League of Legends: Season 2 (Soundtrack from the Animated Series)",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "B-XivnZunVM",
name: "Heavy Is The Crown (Original Score)",
duration: Some(102),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/AYcl_X1V-eF8utaaVUlgmY-ibcQwE2BsY0RW6TdbZ5qAK8UNUfA5xaNiERyCHv2PpsXNh_L3hPZkmdM=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/AYcl_X1V-eF8utaaVUlgmY-ibcQwE2BsY0RW6TdbZ5qAK8UNUfA5xaNiERyCHv2PpsXNh_L3hPZkmdM=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCjZCpUyBTuYRQhkYKZR_mdg"),
name: "Mike Shinoda",
),
ArtistId(
id: Some("UCCDcGPkq3rOACsM5_j5QiHg"),
name: "Emily Armstrong",
),
],
artist_id: Some("UCjZCpUyBTuYRQhkYKZR_mdg"),
album: Some(AlbumId(
id: "MPREb_muqZ7sOFHBp",
name: "Arcane League of Legends: Season 2 (Soundtrack from the Animated Series)",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "ZeIneYtQ1rw",
name: "CASE 143",
duration: Some(192),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/gHEirg29K3Qf3JREf5nXADzhEsWvG60jF3qzOBTZ-ZLGRdNp64_lcj-pI5GMrkhy1JPU5EIDE4WgmpU=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/gHEirg29K3Qf3JREf5nXADzhEsWvG60jF3qzOBTZ-ZLGRdNp64_lcj-pI5GMrkhy1JPU5EIDE4WgmpU=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCIMmuidNJdncfMEelOU08Fg"),
name: "Stray Kids",
),
],
artist_id: Some("UCIMmuidNJdncfMEelOU08Fg"),
album: Some(AlbumId(
id: "MPREb_NuxPbSpDTkj",
name: "MAXIDENT",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "8Go0B7mNcsU",
name: "Railway (방찬) Railway (Bang Chan)",
duration: Some(174),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/FV8clXeLy7dam8rYixnT7x-6nuTyb6qkusqgW4emZJYaU0XgKf95oIozIHvgB9BtETuneDd0XJauH3lO=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/FV8clXeLy7dam8rYixnT7x-6nuTyb6qkusqgW4emZJYaU0XgKf95oIozIHvgB9BtETuneDd0XJauH3lO=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCIMmuidNJdncfMEelOU08Fg"),
name: "Stray Kids",
),
],
artist_id: Some("UCIMmuidNJdncfMEelOU08Fg"),
album: Some(AlbumId(
id: "MPREb_hVsLiyk7ZIe",
name: "合 (HOP) HOP",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "of7yhvIadWo",
name: "Walkin On Water (HIP Ver.)",
duration: Some(176),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/FV8clXeLy7dam8rYixnT7x-6nuTyb6qkusqgW4emZJYaU0XgKf95oIozIHvgB9BtETuneDd0XJauH3lO=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/FV8clXeLy7dam8rYixnT7x-6nuTyb6qkusqgW4emZJYaU0XgKf95oIozIHvgB9BtETuneDd0XJauH3lO=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCIMmuidNJdncfMEelOU08Fg"),
name: "Stray Kids",
),
],
artist_id: Some("UCIMmuidNJdncfMEelOU08Fg"),
album: Some(AlbumId(
id: "MPREb_hVsLiyk7ZIe",
name: "合 (HOP) HOP",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "iqeY3sz8ldk",
name: "U (feat. TABLO)",
duration: Some(164),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/FV8clXeLy7dam8rYixnT7x-6nuTyb6qkusqgW4emZJYaU0XgKf95oIozIHvgB9BtETuneDd0XJauH3lO=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/FV8clXeLy7dam8rYixnT7x-6nuTyb6qkusqgW4emZJYaU0XgKf95oIozIHvgB9BtETuneDd0XJauH3lO=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCIMmuidNJdncfMEelOU08Fg"),
name: "Stray Kids",
),
],
artist_id: Some("UCIMmuidNJdncfMEelOU08Fg"),
album: Some(AlbumId(
id: "MPREb_hVsLiyk7ZIe",
name: "合 (HOP) HOP",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "vRmkqYlH-nA",
name: "Bounce Back",
duration: Some(184),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/FV8clXeLy7dam8rYixnT7x-6nuTyb6qkusqgW4emZJYaU0XgKf95oIozIHvgB9BtETuneDd0XJauH3lO=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/FV8clXeLy7dam8rYixnT7x-6nuTyb6qkusqgW4emZJYaU0XgKf95oIozIHvgB9BtETuneDd0XJauH3lO=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCIMmuidNJdncfMEelOU08Fg"),
name: "Stray Kids",
),
],
artist_id: Some("UCIMmuidNJdncfMEelOU08Fg"),
album: Some(AlbumId(
id: "MPREb_hVsLiyk7ZIe",
name: "合 (HOP) HOP",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "ovHoY8UBIu8",
name: "Walkin On Water",
duration: Some(172),
cover: [
Thumbnail(
url: "https://i.ytimg.com/vi/ovHoY8UBIu8/sddefault.jpg?sqp=-oaymwEWCJADEOEBIAQqCghqEJQEGHgg6AJIWg&rs=AMzJL3nT3hLBjoOobwzYb9uspvK59B2j7A",
width: 400,
height: 225,
),
],
artists: [
ArtistId(
id: Some("UCIMmuidNJdncfMEelOU08Fg"),
name: "Stray Kids",
),
ArtistId(
id: None,
name: "26M views",
),
],
artist_id: Some("UCIMmuidNJdncfMEelOU08Fg"),
album: None,
view_count: None,
track_type: video,
track_nr: None,
by_va: false,
),
],
ctoken: None,
endpoint: music_browse,
authenticated: true,
)

View file

@ -0,0 +1,63 @@
---
source: src/client/pagination.rs
expression: paginator
---
Paginator(
count: Some(2),
items: [
AlbumItem(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w226-h226-l90-rj",
width: 226,
height: 226,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w544-h544-l90-rj",
width: 544,
height: 544,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album_type: album,
year: None,
by_va: false,
),
AlbumItem(
id: "MPREb_IRSjexVmMMl",
name: "21",
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/y6b4yT6dCKEVmzBvATUWodOFLYc81vwxuK0nTgE-scZ3BvyuY9639NL_UyGc6zc_ASoELG67fDUNta0=w226-h226-l90-rj",
width: 226,
height: 226,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/y6b4yT6dCKEVmzBvATUWodOFLYc81vwxuK0nTgE-scZ3BvyuY9639NL_UyGc6zc_ASoELG67fDUNta0=w544-h544-l90-rj",
width: 544,
height: 544,
),
],
artists: [
ArtistId(
id: Some("UCRw0x9_EfawqmgDI2IgQLLg"),
name: "Adele",
),
],
artist_id: Some("UCRw0x9_EfawqmgDI2IgQLLg"),
album_type: album,
year: None,
by_va: false,
),
],
ctoken: None,
endpoint: music_browse,
)

View file

@ -0,0 +1,436 @@
---
source: src/client/pagination.rs
expression: paginator
---
Paginator(
count: None,
items: [
ArtistItem(
id: "UCbUG4bsuazynh4cqGUs4oOA",
name: "Tom Twers",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/BTkzjLKIpmDl6cqtfHtIpJfywBSc7Rl_ttA1Np6gBqnZv1F5wh0bvZ-7DESc55qgBMKX3jjD-SacdMk8xQ=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/BTkzjLKIpmDl6cqtfHtIpJfywBSc7Rl_ttA1Np6gBqnZv1F5wh0bvZ-7DESc55qgBMKX3jjD-SacdMk8xQ=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCAhzIe-pG3XuZzGzoF3X0YA",
name: "KATI K",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/a-/ALV-UjWIQp0M5CuQM_KurfNoyLdT20hSRlTnoNs8x-9LRLVQy2TOKRjr=w60-h60-l90-rj-dcpWWY7KUI",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/a-/ALV-UjWIQp0M5CuQM_KurfNoyLdT20hSRlTnoNs8x-9LRLVQy2TOKRjr=w120-h120-l90-rj-dcpWWY7KUI",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCXJkrGR8Pq2PsrpAKzmGnLA",
name: "TOBIAS",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/MxIerzIwMIzXl-K6VkcJyeZFFZ9CrJOjOEL5kkQescyaLxIhI2sh44GBhnisDifYzHabuKGtQbmRJOJFlQ=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/MxIerzIwMIzXl-K6VkcJyeZFFZ9CrJOjOEL5kkQescyaLxIhI2sh44GBhnisDifYzHabuKGtQbmRJOJFlQ=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCp1Rxq0nIVoeljRfJn8yKbg",
name: "KAYEF",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/p_S4WXz6nJaC6yj7GkVHB3jCMV7at_9kfnAYmE3BfHpcmqUfmdYiA9xqs4tmrHfcjplPMWpy4IXvY9Y=w60-h60-p-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/p_S4WXz6nJaC6yj7GkVHB3jCMV7at_9kfnAYmE3BfHpcmqUfmdYiA9xqs4tmrHfcjplPMWpy4IXvY9Y=w120-h120-p-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UC7e8YXFjQLnKmxqRBZ7IwWw",
name: "Monet192",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/W1PqoAb7rcFIVlAdFMRV97gQ42yXnvKnO8M4-RKkiI486MPO7Yfrt-SMRDruclBAMpzK35GF6Gk5RaU=w60-h60-p-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/W1PqoAb7rcFIVlAdFMRV97gQ42yXnvKnO8M4-RKkiI486MPO7Yfrt-SMRDruclBAMpzK35GF6Gk5RaU=w120-h120-p-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCCLOqu8H6HeY3iShcAPn-qA",
name: "Mathea",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/yHpC193aCbr41gj0YZXiEjbgqFMuQTByVAORuBHr9bF9SahUy3UlMcIFLUY1Aw2PBCenM2uM50Zqbg=w60-h60-p-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/yHpC193aCbr41gj0YZXiEjbgqFMuQTByVAORuBHr9bF9SahUy3UlMcIFLUY1Aw2PBCenM2uM50Zqbg=w120-h120-p-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCDC9A-NGJwoEMyzULQWf4kw",
name: "Pantha",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/SLaGBWrx951kbmc5pPN_Z1x0rjPJP1ViOyMphH0bM1XAXPOBtPAYHnw7qOTThNPy2QL4wWwBGW2Xq2o=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/SLaGBWrx951kbmc5pPN_Z1x0rjPJP1ViOyMphH0bM1XAXPOBtPAYHnw7qOTThNPy2QL4wWwBGW2Xq2o=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCgosMU69MpoCqhuS1JZj6Cw",
name: "Sido",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/HZpnexwxNS5FkIrpz6hdHZuNhBS-GKjs0C9NU8nDSTmHFlPaviqxV-dDLS_ubSEbpEvu0m2P2WT3kaQ=w60-h60-p-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/HZpnexwxNS5FkIrpz6hdHZuNhBS-GKjs0C9NU8nDSTmHFlPaviqxV-dDLS_ubSEbpEvu0m2P2WT3kaQ=w120-h120-p-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCK2ZLsY9Mb_dxZiZfKE3lGg",
name: "AnnenMayKantereit",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/UIiP1HBod3H5OsOTyhPZPdfuU5-eslS9Wr8vy8aBphF1g22bk3QNK7O5vX5wjm3iZXQtZc-sYyw3BbM=w60-h60-p-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/UIiP1HBod3H5OsOTyhPZPdfuU5-eslS9Wr8vy8aBphF1g22bk3QNK7O5vX5wjm3iZXQtZc-sYyw3BbM=w120-h120-p-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCA-uIWGyE0n9YvJ-titY8zA",
name: "LOTTE",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/l_pq6m1w2cJiBfmK7Vqzv5rYJOrG_4IAaZFmQg4AogonohFXbTVfDTvXcc0h8IBc351ccbZ-QtwDnSU=w60-h60-p-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/l_pq6m1w2cJiBfmK7Vqzv5rYJOrG_4IAaZFmQg4AogonohFXbTVfDTvXcc0h8IBc351ccbZ-QtwDnSU=w120-h120-p-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCZwxolUWIeUty9Ru39pKNIw",
name: "Revolverheld",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/cTkCPvR4t9FpuODFJpRYKn1YHq_wfTRIWwRCKZze5snlrC_9DZ6GNFL5P4i4UypIRjFiTqEDiSc3NW0=w60-h60-p-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/cTkCPvR4t9FpuODFJpRYKn1YHq_wfTRIWwRCKZze5snlrC_9DZ6GNFL5P4i4UypIRjFiTqEDiSc3NW0=w120-h120-p-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCgM_Xg_Fmud497MdTPA3vTQ",
name: "Civo",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/a-/ALV-UjUxmCuTsP_JsJVV9c8lymt9auPNUk0QA1F1uXi7kA41JH4_O2U=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/a-/ALV-UjUxmCuTsP_JsJVV9c8lymt9auPNUk0QA1F1uXi7kA41JH4_O2U=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UC47yhBXrBIM5glZahA2nHqA",
name: "ENNIO",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/QXs3QaafSYpgbZtO-HHuiGJUq9Q66l6r3TwrKxantR1JNGV9rLNyoL75D6U7C1ctPHruYrGtOn0AXQzh=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/QXs3QaafSYpgbZtO-HHuiGJUq9Q66l6r3TwrKxantR1JNGV9rLNyoL75D6U7C1ctPHruYrGtOn0AXQzh=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCtKjWSYT3K5Kapp8ijffdqg",
name: "Esther Graf",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/IJuCGSYAWOqH5n6YnQ3SnyKelJklN3zfowm7Av9tKEXwWuBQ326HPhURvqXO5vDV9dHaWfBu-J_8aZM=w60-h60-p-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/IJuCGSYAWOqH5n6YnQ3SnyKelJklN3zfowm7Av9tKEXwWuBQ326HPhURvqXO5vDV9dHaWfBu-J_8aZM=w120-h120-p-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UC4OHFyfFMFfJAVDwPhgWPQg",
name: "AYLIVA",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/a-/ALV-UjVX1hMbTYzmBtTWYR728KdQ3H-_Q8FUWFzpW6YT7wajtbyHBz0=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/a-/ALV-UjVX1hMbTYzmBtTWYR728KdQ3H-_Q8FUWFzpW6YT7wajtbyHBz0=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCKvQnv9ldMbsIQcv1nEd8hw",
name: "TeeageBeatz",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/msZATojvcifqpwJYwA3OhiCqGx3bEQ3QT3c22myd8vtCOwNRE3XTc4vhYVaOUA4_E5xL3ZaXCtjDX_4=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/msZATojvcifqpwJYwA3OhiCqGx3bEQ3QT3c22myd8vtCOwNRE3XTc4vhYVaOUA4_E5xL3ZaXCtjDX_4=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UC6w-5zGi-f14mEqaZ1ELNSg",
name: "H1",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/zoiAmwuTz4s9G852ylrpFP83yPFvmAM0QkQO1SlvYS4RSBifC2pzSJ6kOomFW6VhgDrbNC3KAEmbH9k=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/zoiAmwuTz4s9G852ylrpFP83yPFvmAM0QkQO1SlvYS4RSBifC2pzSJ6kOomFW6VhgDrbNC3KAEmbH9k=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCBNkQnYHkB48rIsYc2E05KA",
name: "Emilio",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/aLlAoBcb_LgKaj6FowCIpnAXp6UWtSya1mHNQSBTheXi_yy-TrWi6cULoF5j1yyJfHKDzkpWontLz_bP=w60-h60-p-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/aLlAoBcb_LgKaj6FowCIpnAXp6UWtSya1mHNQSBTheXi_yy-TrWi6cULoF5j1yyJfHKDzkpWontLz_bP=w120-h120-p-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCe84h-P4fPif21lyK5vLcww",
name: "Georg Stengel",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/C7QW5VlGgogqLMT2idOC5DG4FVITp9Twfakxp30jw0Q3oFoGMRh-uzvilDy8VwmvSl9YxRIMhwxtrQP8=w60-h60-p-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/C7QW5VlGgogqLMT2idOC5DG4FVITp9Twfakxp30jw0Q3oFoGMRh-uzvilDy8VwmvSl9YxRIMhwxtrQP8=w120-h120-p-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UC4olRzWJj1o2mgbyTBmeaBw",
name: "Gregor Hägele",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/4srE8F_wmdCpoSLTtp2Tz2uBWA6bEqRwx4aVbbyhMrqJXuU8XD0An1vcntKZT2YvVy1aXU064iqGRZZb=w60-h60-p-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/4srE8F_wmdCpoSLTtp2Tz2uBWA6bEqRwx4aVbbyhMrqJXuU8XD0An1vcntKZT2YvVy1aXU064iqGRZZb=w120-h120-p-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCvxPDEZ_Mn0UVKgGrQ-4ZFQ",
name: "DUEJA",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/ShIWzUmvGOdfHZINYLVaZ0MsGLQJx0BIjVNY7Ao_GQIkYmWmWVlWoxV4MDrFRB-QR3GN7vvDcRC0Ug=w60-h60-p-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/ShIWzUmvGOdfHZINYLVaZ0MsGLQJx0BIjVNY7Ao_GQIkYmWmWVlWoxV4MDrFRB-QR3GN7vvDcRC0Ug=w120-h120-p-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCxO_-oK9EPp4hXj42aY1aVQ",
name: "Julia Meladin",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/WXPOsPYwCCFA-sMCmV7gXlJqSUrR70odOeHbYt9ACCLXWsU8lhPcFDRd8qtFnD7rqUZ9x5aHjcfazNQ=w60-h60-p-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/WXPOsPYwCCFA-sMCmV7gXlJqSUrR70odOeHbYt9ACCLXWsU8lhPcFDRd8qtFnD7rqUZ9x5aHjcfazNQ=w120-h120-p-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UClWYTAbTNH9p80shT8yPHqg",
name: "Alexander Eder",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/WCv6cD-rwjI2gYRwYjAskLaNK0kVJgzypcLOV-7nSfPwwYCzruldKpcnGDRbz8UQm6XzLx6MZqPor80=w60-h60-p-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/WCv6cD-rwjI2gYRwYjAskLaNK0kVJgzypcLOV-7nSfPwwYCzruldKpcnGDRbz8UQm6XzLx6MZqPor80=w120-h120-p-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCE7_p3lcXA-YXRZp2PjrgYw",
name: "Helene Fischer",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/mwiYuHAa4o3m8FELdQ1PTIXzXo3F34BR5QBIIVdpqbWGjRriygOZ7yTSg5v40W-gZvxLry0nrwmAkw=w60-h60-p-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/mwiYuHAa4o3m8FELdQ1PTIXzXo3F34BR5QBIIVdpqbWGjRriygOZ7yTSg5v40W-gZvxLry0nrwmAkw=w120-h120-p-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
ArtistItem(
id: "UCLnMizH28AmXN6mqbAKwfgQ",
name: "Marina Marx",
avatar: [
Thumbnail(
url: "https://lh3.googleusercontent.com/Zbq4nSO176iOzElPCIU3jfjhS5ybKugXrODxWTZIEyzDcLquFUeS-aFDTc_r3xBP1rwYwZbW9-rBJFqv=w60-h60-p-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/Zbq4nSO176iOzElPCIU3jfjhS5ybKugXrODxWTZIEyzDcLquFUeS-aFDTc_r3xBP1rwYwZbW9-rBJFqv=w120-h120-p-l90-rj",
width: 120,
height: 120,
),
],
subscriber_count: None,
),
],
ctoken: Some("4qmFsgJiEh5GRW11c2ljX2xpYnJhcnlfY29ycHVzX2FydGlzdHMaQENCbDZJME5vWjB0R1FXOUZRMEZCVVVGU1NVMURUM3BHYjNKelIwVk5RMjU2Y1ZWRVIwRkJnZ01HS2dRSUFCQUI%3D"),
endpoint: music_browse,
)

View file

@ -0,0 +1,71 @@
---
source: src/client/pagination.rs
expression: paginator
---
Paginator(
count: Some(3),
items: [
MusicPlaylistItem(
id: "LM",
name: "Liked Music",
thumbnail: [
Thumbnail(
url: "https://www.gstatic.com/youtube/media/ytm/images/pbg/liked-music-@192.png",
width: 192,
height: 192,
),
Thumbnail(
url: "https://www.gstatic.com/youtube/media/ytm/images/pbg/liked-music-@576.png",
width: 576,
height: 576,
),
],
channel: None,
track_count: None,
from_ytm: true,
is_podcast: false,
),
MusicPlaylistItem(
id: "RDCLAK5uy_k8X5cgTG2AnXbnkwEV7uQFptEKvSRmkjU",
name: "Lautstark: German Indie & Rock Hits",
thumbnail: [
Thumbnail(
url: "https://lh3.googleusercontent.com/NTyTuhySZTQhdmlh19HQBI7hg_PtS1bLATodAepPXjyF1et8SXiqLtJxmVRszGdpXOwkQSIUsMOWgSo=w226-h226-l90-rj",
width: 226,
height: 226,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/NTyTuhySZTQhdmlh19HQBI7hg_PtS1bLATodAepPXjyF1et8SXiqLtJxmVRszGdpXOwkQSIUsMOWgSo=w544-h544-l90-rj",
width: 544,
height: 544,
),
],
channel: None,
track_count: None,
from_ytm: false,
is_podcast: false,
),
MusicPlaylistItem(
id: "SE",
name: "Episodes for Later",
thumbnail: [
Thumbnail(
url: "https://www.gstatic.com/youtube/media/ytm/images/pbg/saved-episodes-@192.png",
width: 192,
height: 192,
),
Thumbnail(
url: "https://www.gstatic.com/youtube/media/ytm/images/pbg/saved-episodes-@576.png",
width: 576,
height: 576,
),
],
channel: None,
track_count: None,
from_ytm: true,
is_podcast: false,
),
],
ctoken: None,
endpoint: music_browse,
)

View file

@ -0,0 +1,811 @@
---
source: src/client/pagination.rs
expression: paginator
---
Paginator(
count: None,
items: [
TrackItem(
id: "02UC3iagcJQ",
name: "Brüder, lasst uns gehen (Instrumental)",
duration: Some(148),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "0uSu-jHdDd4",
name: "Westwind",
duration: Some(188),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "11pkE_azpBQ",
name: "Leave her, Johnny (feat. The O\'Reillys and the Paddyhats)",
duration: Some(173),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "1tJPy7XlfCQ",
name: "Felsenfest",
duration: Some(180),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "2JpUBTLjfPA",
name: "Dreht sich der Wind (Instrumental)",
duration: Some(178),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "6WiuMIp9B6Y",
name: "Westwind (Instrumental)",
duration: Some(189),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "8nfNNAlsTTA",
name: "My Love\'s in Germany (Instrumental)",
duration: Some(195),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "C1U6oXpz4As",
name: "Wein & Wahrheit",
duration: Some(190),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "DvKw0jTUN-s",
name: "Teufelsgeiger",
duration: Some(206),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "GDSVX--bsRU",
name: "Korobeiniki\u{a0}\u{a0}\u{a0}\u{a0}\u{a0}\u{a0}",
duration: Some(174),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "I-ArzgPbNx8",
name: "Merseburger Zauberspruch (feat. Luc Arbogast)",
duration: Some(225),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "OElgvIuT8TY",
name: "Trink mein Freund",
duration: Some(172),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "RGlOEBJyhrc",
name: "Drei schwarze Reiter (Instrumental)",
duration: Some(189),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "UUCM1WR611g",
name: "Tanz in den Mai",
duration: Some(199),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "WlzrZsEtWbs",
name: "Tanz in den Mai (Instrumental)",
duration: Some(199),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "apY7iYoYtD8",
name: "Dreht sich der Wind",
duration: Some(177),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "azSEPwrxG2c",
name: "Bella Ciao (Versione italiana - Instrumental)",
duration: Some(188),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "b9FNHTk-tAM",
name: "Merseburger Zauberspruch (Instrumental)",
duration: Some(224),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "eAeGr78VYxM",
name: "Auld Lang Syne\u{a0}\u{a0}\u{a0}\u{a0} (Instrumental)",
duration: Some(175),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "efCzrXOWM0Q",
name: "Freiheit & Tod\u{a0}",
duration: Some(195),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "fbmHQLiF1qs",
name: "Vino griego",
duration: Some(209),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "h0qhAXLom-Y",
name: "C\'est la vie (English Version - Instrumental)",
duration: Some(198),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "hTL2vAvAbNM",
name: "Teufelsgeiger (Instrumental)",
duration: Some(206),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "hptOG4EVgMs",
name: "Wein & Wahrheit (Instrumental)",
duration: Some(190),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
TrackItem(
id: "iRIlHsC8xL8",
name: "Pulverdampf & Donnergroll\'n",
duration: Some(202),
cover: [
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w60-h60-l90-rj",
width: 60,
height: 60,
),
Thumbnail(
url: "https://lh3.googleusercontent.com/68I8CkjpCfwrRy1JXyS5tvo_R0hm3DlEE0pXNnLpuihH10pIRNlNVZEoqsEFOzPeCAKMEjsJKQ5kfHVW=w120-h120-l90-rj",
width: 120,
height: 120,
),
],
artists: [
ArtistId(
id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
name: "dArtagnan",
),
],
artist_id: Some("UCWOw75Vmryv3D_WdzE2DbKA"),
album: Some(AlbumId(
id: "MPREb_yYq4IkZhG9j",
name: "Felsenfest",
)),
view_count: None,
track_type: track,
track_nr: None,
by_va: false,
),
],
ctoken: Some("4qmFsgJcEhRGRW11c2ljX2xpa2VkX3ZpZGVvcxpEQ0JwNkprTkNhMUZIVW05SlEwRkZVVUZvYjBOYVZ6UnBSRUZxWXpsaVF6ZENhRU4yT1ZwdFRVRjNnZ01HS2dRSUFCQUI%3D"),
endpoint: music_browse,
)

File diff suppressed because it is too large Load diff

View file

@ -359,6 +359,7 @@ impl MapResponse<VideoDetails> for response::VideoDetails {
comment_ctoken,
visitor_data.clone(),
ContinuationEndpoint::Next,
false,
),
latest_comments: Paginator::new_ext(
comment_count,
@ -366,6 +367,7 @@ impl MapResponse<VideoDetails> for response::VideoDetails {
latest_comments_ctoken,
visitor_data.clone(),
ContinuationEndpoint::Next,
false,
),
visitor_data,
},
@ -484,6 +486,7 @@ fn map_recommendations(
mapper.ctoken,
visitor_data,
ContinuationEndpoint::Next,
false,
),
warnings: mapper.warnings,
}

View file

@ -1,5 +1,7 @@
//! Wrapper model for progressively fetched items
use std::ops::Not;
use serde::{Deserialize, Serialize};
/// Wrapper around progressively fetched items
@ -33,6 +35,9 @@ pub struct Paginator<T> {
pub visitor_data: Option<String>,
/// YouTube API endpoint to fetch continuations from
pub endpoint: ContinuationEndpoint,
/// True if the paginator should be fetched with YouTube credentials
#[serde(default, skip_serializing_if = "<&bool>::not")]
pub authenticated: bool,
}
impl<T> Default for Paginator<T> {
@ -43,6 +48,7 @@ impl<T> Default for Paginator<T> {
ctoken: None,
visitor_data: None,
endpoint: ContinuationEndpoint::Browse,
authenticated: false,
}
}
}
@ -82,7 +88,14 @@ impl ContinuationEndpoint {
impl<T> Paginator<T> {
pub(crate) fn new(count: Option<u64>, items: Vec<T>, ctoken: Option<String>) -> Self {
Self::new_ext(count, items, ctoken, None, ContinuationEndpoint::Browse)
Self::new_ext(
count,
items,
ctoken,
None,
ContinuationEndpoint::Browse,
false,
)
}
pub(crate) fn new_ext(
@ -91,6 +104,7 @@ impl<T> Paginator<T> {
ctoken: Option<String>,
visitor_data: Option<String>,
endpoint: ContinuationEndpoint,
authenticated: bool,
) -> Self {
Self {
count: match ctoken {
@ -101,6 +115,7 @@ impl<T> Paginator<T> {
ctoken,
visitor_data,
endpoint,
authenticated,
}
}

View file

@ -319,7 +319,9 @@ fn map_text_component(
browse_id: watch_playlist_endpoint.playlist_id,
verification,
},
None => TextComponent::Text { text, style },
None | Some(NavigationEndpoint::CreatePlaylist { .. }) => {
TextComponent::Text { text, style }
}
}
}

View file

@ -12,7 +12,6 @@ use std::{
str::{FromStr, SplitWhitespace},
};
use base64::Engine;
use fancy_regex::RegexBuilder;
use once_cell::sync::Lazy;
use rand::Rng;
@ -20,7 +19,7 @@ use regex::Regex;
use url::Url;
use crate::{
error::Error,
error::{AuthError, Error, ExtractionError},
param::{Country, Language, COUNTRIES},
serializer::text::TextComponent,
};
@ -502,11 +501,11 @@ pub fn video_id_from_thumbnail_url(url: &str) -> Option<String> {
}
pub fn b64_encode<T: AsRef<[u8]>>(input: T) -> String {
base64::engine::general_purpose::URL_SAFE.encode(input)
data_encoding::BASE64URL.encode(input.as_ref())
}
pub fn b64_decode<T: AsRef<[u8]>>(input: T) -> Result<Vec<u8>, base64::DecodeError> {
base64::engine::general_purpose::URL_SAFE.decode(input)
pub fn b64_decode<T: AsRef<[u8]>>(input: T) -> Result<Vec<u8>, data_encoding::DecodeError> {
data_encoding::BASE64URL.decode(input.as_ref())
}
/// Get the country from its English name
@ -597,6 +596,18 @@ where
}
}
/// Map error when fetching an internal playlist
///
/// If no user is logged in, YouTube returns a "NotFound" error. This has to be corrected
/// into a NoLogin error.
pub fn map_internal_playlist_err(e: Error) -> Error {
if let Error::Extraction(ExtractionError::NotFound { .. }) = e {
Error::Auth(AuthError::NoLogin)
} else {
e
}
}
#[cfg(test)]
pub(crate) mod tests {
use std::{fs::File, io::BufReader, path::PathBuf};

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

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

File diff suppressed because it is too large Load diff

View file

@ -2738,11 +2738,143 @@ async fn isrc_search_languages(rp: RustyPipe) {
#[tokio::test]
async fn user_auth_check_login(rp: RustyPipe, auth_enabled: bool) {
if auth_enabled {
assert!(rp.query().auth_enabled());
assert!(rp.query().auth_enabled(ClientType::Tv));
rp.user_auth_check_login().await.unwrap();
}
}
#[rstest]
#[tokio::test]
async fn history(rp: RustyPipe, cookie_auth_enabled: bool) {
if !cookie_auth_enabled {
return;
}
let videos = rp.query().history().await.unwrap();
assert_next_items(videos, rp.query(), 100).await;
}
#[rstest]
#[tokio::test]
async fn history_search(rp: RustyPipe, cookie_auth_enabled: bool) {
if !cookie_auth_enabled {
return;
}
let videos = rp.query().history_search("a").await.unwrap();
assert_next_items(videos, rp.query(), 5).await;
}
#[rstest]
#[tokio::test]
async fn subscriptions(rp: RustyPipe, cookie_auth_enabled: bool) {
if !cookie_auth_enabled {
return;
}
let channels = rp.query().subscriptions().await.unwrap();
assert_next_items(channels, rp.query(), 5).await;
}
#[rstest]
#[tokio::test]
async fn subscription_feed(rp: RustyPipe, cookie_auth_enabled: bool) {
if !cookie_auth_enabled {
return;
}
let videos = rp.query().subscription_feed().await.unwrap();
assert_next_items(videos, rp.query(), 50).await;
}
#[rstest]
#[tokio::test]
async fn liked_videos(rp: RustyPipe, cookie_auth_enabled: bool) {
if !cookie_auth_enabled {
return;
}
let videos = rp.query().liked_videos().await.unwrap();
assert_next_items(videos.videos, rp.query(), 5).await;
}
#[rstest]
#[tokio::test]
async fn watch_later(rp: RustyPipe, cookie_auth_enabled: bool) {
if !cookie_auth_enabled {
return;
}
let videos = rp.query().watch_later().await.unwrap();
assert_next_items(videos.videos, rp.query(), 5).await;
}
#[rstest]
#[tokio::test]
async fn music_history(rp: RustyPipe, cookie_auth_enabled: bool) {
if !cookie_auth_enabled {
return;
}
let tracks = rp.query().music_history().await.unwrap();
assert_next_items(tracks, rp.query(), 150).await;
}
#[rstest]
#[tokio::test]
async fn music_saved_artists(rp: RustyPipe, cookie_auth_enabled: bool) {
if !cookie_auth_enabled {
return;
}
let artists = rp.query().music_saved_artists().await.unwrap();
assert_next_items(artists, rp.query(), 5).await;
}
#[rstest]
#[tokio::test]
async fn music_saved_albums(rp: RustyPipe, cookie_auth_enabled: bool) {
if !cookie_auth_enabled {
return;
}
let albums = rp.query().music_saved_albums().await.unwrap();
assert_next_items(albums, rp.query(), 5).await;
}
#[rstest]
#[tokio::test]
async fn music_saved_tracks(rp: RustyPipe, cookie_auth_enabled: bool) {
if !cookie_auth_enabled {
return;
}
let tracks = rp.query().music_saved_tracks().await.unwrap();
assert_next_items(tracks, rp.query(), 50).await;
}
#[rstest]
#[tokio::test]
async fn music_saved_playlists(rp: RustyPipe, cookie_auth_enabled: bool) {
if !cookie_auth_enabled {
return;
}
let playlists = rp.query().music_saved_playlists().await.unwrap();
assert_next_items(playlists, rp.query(), 5).await;
}
#[rstest]
#[tokio::test]
async fn music_liked_tracks(rp: RustyPipe, cookie_auth_enabled: bool) {
if !cookie_auth_enabled {
return;
}
let tracks = rp.query().music_liked_tracks().await.unwrap();
assert_next_items(tracks.tracks, rp.query(), 5).await;
}
//#TESTUTIL
/// Get the language setting from the environment variable
@ -2778,7 +2910,17 @@ fn auth_enabled(rp: RustyPipe) -> bool {
std::env::var("YT_AUTHENTICATED")
.map(|v| !v.is_empty() && v.to_ascii_lowercase() != "false")
.unwrap_or_default()
|| rp.query().auth_enabled()
|| rp.query().auth_enabled(ClientType::Desktop)
|| rp.query().auth_enabled(ClientType::Tv)
}
/// Get a flag signaling if an authenticated user is expected
#[fixture]
fn cookie_auth_enabled(rp: RustyPipe) -> bool {
std::env::var("YT_AUTHENTICATED")
.map(|v| !v.is_empty() && v.to_ascii_lowercase() != "false")
.unwrap_or_default()
|| rp.query().auth_enabled(ClientType::Desktop)
}
/*