Compare commits
2 commits
main
...
feat/user-
Author | SHA1 | Date | |
---|---|---|---|
5044cb0ed5 | |||
46b398e3b6 |
42 changed files with 176972 additions and 70 deletions
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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
174
src/client/history.rs
Normal 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]",
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
182
src/client/music_history.rs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
8
src/client/response/history.rs
Normal file
8
src/client/response/history.rs
Normal 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>>,
|
||||
}
|
|
@ -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 {}
|
||||
|
|
8
src/client/response/music_history.rs
Normal file
8
src/client/response/music_history.rs
Normal file
|
@ -0,0 +1,8 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
use super::music_playlist::Contents;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MusicHistory {
|
||||
pub contents: Contents,
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
File diff suppressed because it is too large
Load diff
|
@ -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,
|
||||
)
|
|
@ -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,
|
||||
)
|
|
@ -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,
|
||||
)
|
|
@ -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,
|
||||
)
|
|
@ -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
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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};
|
||||
|
|
7812
testfiles/channel/channel_playlists.json
Normal file
7812
testfiles/channel/channel_playlists.json
Normal file
File diff suppressed because it is too large
Load diff
5061
testfiles/history/history.json
Normal file
5061
testfiles/history/history.json
Normal file
File diff suppressed because it is too large
Load diff
76628
testfiles/history/subscription_feed.json
Normal file
76628
testfiles/history/subscription_feed.json
Normal file
File diff suppressed because it is too large
Load diff
45121
testfiles/history/subscriptions.json
Normal file
45121
testfiles/history/subscriptions.json
Normal file
File diff suppressed because it is too large
Load diff
11448
testfiles/music_history/music_history.json
Normal file
11448
testfiles/music_history/music_history.json
Normal file
File diff suppressed because it is too large
Load diff
1429
testfiles/music_history/saved_albums.json
Normal file
1429
testfiles/music_history/saved_albums.json
Normal file
File diff suppressed because it is too large
Load diff
4188
testfiles/music_history/saved_artists.json
Normal file
4188
testfiles/music_history/saved_artists.json
Normal file
File diff suppressed because it is too large
Load diff
1723
testfiles/music_history/saved_playlists.json
Normal file
1723
testfiles/music_history/saved_playlists.json
Normal file
File diff suppressed because it is too large
Load diff
14230
testfiles/music_history/saved_tracks.json
Normal file
14230
testfiles/music_history/saved_tracks.json
Normal file
File diff suppressed because it is too large
Load diff
146
tests/youtube.rs
146
tests/youtube.rs
|
@ -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)
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
Loading…
Reference in a new issue