feat: add timeago parser, playlist_cont
This commit is contained in:
parent
5b8c3d646a
commit
346406c1c8
25 changed files with 11374 additions and 183 deletions
|
|
@ -2,6 +2,9 @@ pub mod player;
|
|||
pub mod playlist;
|
||||
mod response;
|
||||
|
||||
#[cfg(test)]
|
||||
mod scripts;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
|
@ -15,7 +18,7 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use crate::{
|
||||
cache::{Cache, ClientData},
|
||||
model::Locale,
|
||||
model::{Country, Language},
|
||||
util,
|
||||
};
|
||||
|
||||
|
|
@ -68,10 +71,8 @@ struct ClientInfo {
|
|||
platform: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
original_url: Option<String>,
|
||||
/// Language (`en`, `de`)
|
||||
hl: String,
|
||||
/// Country (`US`, `DE`)
|
||||
gl: String,
|
||||
hl: Language,
|
||||
gl: Country,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
|
|
@ -127,7 +128,7 @@ const ANDROID_API_KEY: &str = "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w";
|
|||
const IOS_API_KEY: &str = "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc";
|
||||
const IOS_DEVICE_MODEL: &str = "iPhone14,5";
|
||||
|
||||
const CLIENT_VERSION_REGEXES: Lazy<[Regex; 3]> = Lazy::new(|| {
|
||||
static CLIENT_VERSION_REGEXES: Lazy<[Regex; 3]> = Lazy::new(|| {
|
||||
[
|
||||
Regex::new("INNERTUBE_CONTEXT_CLIENT_VERSION\":\"([0-9\\.]+?)\"").unwrap(),
|
||||
Regex::new("innertube_context_client_version\":\"([0-9\\.]+?)\"").unwrap(),
|
||||
|
|
@ -136,7 +137,7 @@ const CLIENT_VERSION_REGEXES: Lazy<[Regex; 3]> = Lazy::new(|| {
|
|||
});
|
||||
|
||||
pub struct RustyTube {
|
||||
pub locale: Arc<Locale>,
|
||||
localization: Arc<Localization>,
|
||||
cache: Cache,
|
||||
desktop_client: Arc<DesktopClient>,
|
||||
desktop_music_client: Arc<DesktopMusicClient>,
|
||||
|
|
@ -145,17 +146,30 @@ pub struct RustyTube {
|
|||
tvhtml5embed_client: Arc<TvHtml5EmbedClient>,
|
||||
}
|
||||
|
||||
struct Localization {
|
||||
language: Language,
|
||||
content_country: Country,
|
||||
}
|
||||
|
||||
impl RustyTube {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::new_with_ua("en", "US", Some("rusty-tube.json".to_owned()))
|
||||
Self::new_with_ua(
|
||||
Language::En,
|
||||
Country::Us,
|
||||
Some("rusty-tube.json".to_owned()),
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new_with_ua(lang: &str, country: &str, cache_file: Option<String>) -> Self {
|
||||
let locale = Arc::new(Locale {
|
||||
lang: lang.to_owned(),
|
||||
country: country.to_owned(),
|
||||
pub fn new_with_ua(
|
||||
language: Language,
|
||||
content_country: Country,
|
||||
cache_file: Option<String>,
|
||||
) -> Self {
|
||||
let loc = Arc::new(Localization {
|
||||
language,
|
||||
content_country,
|
||||
});
|
||||
|
||||
let cache = match cache_file.as_ref() {
|
||||
|
|
@ -164,13 +178,13 @@ impl RustyTube {
|
|||
};
|
||||
|
||||
Self {
|
||||
locale: locale.clone(),
|
||||
localization: loc.clone(),
|
||||
cache: cache.clone(),
|
||||
desktop_client: Arc::new(DesktopClient::new(locale.clone(), cache.clone())),
|
||||
desktop_music_client: Arc::new(DesktopMusicClient::new(locale.clone(), cache)),
|
||||
android_client: Arc::new(AndroidClient::new(locale.clone())),
|
||||
ios_client: Arc::new(IosClient::new(locale.clone())),
|
||||
tvhtml5embed_client: Arc::new(TvHtml5EmbedClient::new(locale)),
|
||||
desktop_client: Arc::new(DesktopClient::new(loc.clone(), cache.clone())),
|
||||
desktop_music_client: Arc::new(DesktopMusicClient::new(loc.clone(), cache)),
|
||||
android_client: Arc::new(AndroidClient::new(loc.clone())),
|
||||
ios_client: Arc::new(IosClient::new(loc.clone())),
|
||||
tvhtml5embed_client: Arc::new(TvHtml5EmbedClient::new(loc)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -202,7 +216,7 @@ async fn exec_request_text(http: Client, request: Request) -> Result<String> {
|
|||
}
|
||||
|
||||
pub struct DesktopClient {
|
||||
locale: Arc<Locale>,
|
||||
localization: Arc<Localization>,
|
||||
http: Client,
|
||||
cache: Cache,
|
||||
consent_cookie: String,
|
||||
|
|
@ -220,12 +234,12 @@ impl YTClient for DesktopClient {
|
|||
platform: "DESKTOP".to_owned(),
|
||||
original_url: Some("https://www.youtube.com/".to_owned()),
|
||||
hl: match localized {
|
||||
true => self.locale.lang.to_owned(),
|
||||
false => "en".to_owned(),
|
||||
true => self.localization.language,
|
||||
false => Language::En,
|
||||
},
|
||||
gl: match localized {
|
||||
true => self.locale.country.to_owned(),
|
||||
false => "US".to_owned(),
|
||||
true => self.localization.content_country,
|
||||
false => Country::Us,
|
||||
},
|
||||
},
|
||||
request: Some(RequestYT::default()),
|
||||
|
|
@ -260,7 +274,7 @@ impl YTClient for DesktopClient {
|
|||
}
|
||||
|
||||
impl DesktopClient {
|
||||
fn new(locale: Arc<Locale>, cache: Cache) -> Self {
|
||||
fn new(localization: Arc<Localization>, cache: Cache) -> Self {
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
let http = ClientBuilder::new()
|
||||
|
|
@ -271,7 +285,7 @@ impl DesktopClient {
|
|||
.expect("unable to build the HTTP client");
|
||||
|
||||
Self {
|
||||
locale,
|
||||
localization,
|
||||
http,
|
||||
cache,
|
||||
consent_cookie: format!(
|
||||
|
|
@ -329,7 +343,7 @@ impl DesktopClient {
|
|||
}
|
||||
|
||||
pub struct AndroidClient {
|
||||
locale: Arc<Locale>,
|
||||
localization: Arc<Localization>,
|
||||
http: Client,
|
||||
}
|
||||
|
||||
|
|
@ -345,12 +359,12 @@ impl YTClient for AndroidClient {
|
|||
platform: "MOBILE".to_owned(),
|
||||
original_url: None,
|
||||
hl: match localized {
|
||||
true => self.locale.lang.to_owned(),
|
||||
false => "en".to_owned(),
|
||||
true => self.localization.language,
|
||||
false => Language::En,
|
||||
},
|
||||
gl: match localized {
|
||||
true => self.locale.country.to_owned(),
|
||||
false => "US".to_owned(),
|
||||
true => self.localization.content_country,
|
||||
false => Country::Us,
|
||||
},
|
||||
},
|
||||
request: None,
|
||||
|
|
@ -384,22 +398,22 @@ impl YTClient for AndroidClient {
|
|||
}
|
||||
|
||||
impl AndroidClient {
|
||||
fn new(locale: Arc<Locale>) -> Self {
|
||||
fn new(localization: Arc<Localization>) -> Self {
|
||||
let http = ClientBuilder::new()
|
||||
.user_agent(format!(
|
||||
"com.google.android.youtube/{} (Linux; U; Android 12; {}) gzip",
|
||||
MOBILE_CLIENT_VERSION, locale.country
|
||||
MOBILE_CLIENT_VERSION, localization.content_country
|
||||
))
|
||||
.gzip(true)
|
||||
.build()
|
||||
.expect("unable to build the HTTP client");
|
||||
|
||||
Self { locale, http }
|
||||
Self { localization, http }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IosClient {
|
||||
locale: Arc<Locale>,
|
||||
localization: Arc<Localization>,
|
||||
http: Client,
|
||||
}
|
||||
|
||||
|
|
@ -415,12 +429,12 @@ impl YTClient for IosClient {
|
|||
platform: "MOBILE".to_owned(),
|
||||
original_url: None,
|
||||
hl: match localized {
|
||||
true => self.locale.lang.to_owned(),
|
||||
false => "en".to_owned(),
|
||||
true => self.localization.language,
|
||||
false => Language::En,
|
||||
},
|
||||
gl: match localized {
|
||||
true => self.locale.country.to_owned(),
|
||||
false => "US".to_owned(),
|
||||
true => self.localization.content_country,
|
||||
false => Country::Us,
|
||||
},
|
||||
},
|
||||
request: None,
|
||||
|
|
@ -451,22 +465,22 @@ impl YTClient for IosClient {
|
|||
}
|
||||
|
||||
impl IosClient {
|
||||
fn new(locale: Arc<Locale>) -> Self {
|
||||
fn new(localization: Arc<Localization>) -> Self {
|
||||
let http = ClientBuilder::new()
|
||||
.user_agent(format!(
|
||||
"com.google.ios.youtube/{} ({}; U; CPU iOS 15_4 like Mac OS X; {})",
|
||||
MOBILE_CLIENT_VERSION, IOS_DEVICE_MODEL, locale.country
|
||||
MOBILE_CLIENT_VERSION, IOS_DEVICE_MODEL, localization.content_country
|
||||
))
|
||||
.gzip(true)
|
||||
.build()
|
||||
.expect("unable to build the HTTP client");
|
||||
|
||||
Self { locale, http }
|
||||
Self { localization, http }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TvHtml5EmbedClient {
|
||||
locale: Arc<Locale>,
|
||||
localization: Arc<Localization>,
|
||||
http: Client,
|
||||
}
|
||||
|
||||
|
|
@ -482,12 +496,12 @@ impl YTClient for TvHtml5EmbedClient {
|
|||
platform: "TV".to_owned(),
|
||||
original_url: None,
|
||||
hl: match localized {
|
||||
true => self.locale.lang.to_owned(),
|
||||
false => "en".to_owned(),
|
||||
true => self.localization.language,
|
||||
false => Language::En,
|
||||
},
|
||||
gl: match localized {
|
||||
true => self.locale.country.to_owned(),
|
||||
false => "US".to_owned(),
|
||||
true => self.localization.content_country,
|
||||
false => Country::Us,
|
||||
},
|
||||
},
|
||||
request: Some(RequestYT::default()),
|
||||
|
|
@ -523,7 +537,7 @@ impl YTClient for TvHtml5EmbedClient {
|
|||
}
|
||||
|
||||
impl TvHtml5EmbedClient {
|
||||
fn new(locale: Arc<Locale>) -> Self {
|
||||
fn new(localization: Arc<Localization>) -> Self {
|
||||
let http = ClientBuilder::new()
|
||||
.user_agent(DEFAULT_UA)
|
||||
.gzip(true)
|
||||
|
|
@ -531,12 +545,12 @@ impl TvHtml5EmbedClient {
|
|||
.build()
|
||||
.expect("unable to build the HTTP client");
|
||||
|
||||
Self { locale, http }
|
||||
Self { localization, http }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DesktopMusicClient {
|
||||
locale: Arc<Locale>,
|
||||
localization: Arc<Localization>,
|
||||
http: Client,
|
||||
cache: Cache,
|
||||
consent_cookie: String,
|
||||
|
|
@ -554,12 +568,12 @@ impl YTClient for DesktopMusicClient {
|
|||
platform: "DESKTOP".to_owned(),
|
||||
original_url: Some("https://music.youtube.com/".to_owned()),
|
||||
hl: match localized {
|
||||
true => self.locale.lang.to_owned(),
|
||||
false => "en".to_owned(),
|
||||
true => self.localization.language,
|
||||
false => Language::En,
|
||||
},
|
||||
gl: match localized {
|
||||
true => self.locale.country.to_owned(),
|
||||
false => "US".to_owned(),
|
||||
true => self.localization.content_country,
|
||||
false => Country::Us,
|
||||
},
|
||||
},
|
||||
request: Some(RequestYT::default()),
|
||||
|
|
@ -597,7 +611,7 @@ impl YTClient for DesktopMusicClient {
|
|||
}
|
||||
|
||||
impl DesktopMusicClient {
|
||||
fn new(locale: Arc<Locale>, cache: Cache) -> Self {
|
||||
fn new(localization: Arc<Localization>, cache: Cache) -> Self {
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
let http = ClientBuilder::new()
|
||||
|
|
@ -608,7 +622,7 @@ impl DesktopMusicClient {
|
|||
.expect("unable to build the HTTP client");
|
||||
|
||||
Self {
|
||||
locale,
|
||||
localization,
|
||||
http,
|
||||
cache,
|
||||
consent_cookie: format!(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use std::{
|
|||
};
|
||||
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use chrono::{DateTime, NaiveDateTime, NaiveTime, Utc};
|
||||
use chrono::{NaiveDateTime, NaiveTime, TimeZone, Utc};
|
||||
use fancy_regex::Regex;
|
||||
use log::{error, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
|
|
@ -16,8 +16,6 @@ use url::Url;
|
|||
use super::{response, ClientType, ContextYT, RustyTube, YTClient};
|
||||
use crate::{client::response::player, deobfuscate::Deobfuscator, model::*, util};
|
||||
|
||||
// REQUEST
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QPlayer {
|
||||
|
|
@ -369,7 +367,7 @@ fn map_player_data(response: response::Player, deobf: &Deobfuscator) -> Result<V
|
|||
},
|
||||
publish_date: microformat.as_ref().map(|m| {
|
||||
let ndt = NaiveDateTime::new(m.publish_date, NaiveTime::from_hms(0, 0, 0));
|
||||
DateTime::from_utc(ndt, Utc)
|
||||
Utc.from_local_datetime(&ndt).unwrap()
|
||||
}),
|
||||
view_count: video_details.view_count,
|
||||
keywords: video_details
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
// REQUEST
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
|
|
@ -18,10 +16,11 @@ struct QPlaylist {
|
|||
browse_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TmpEntry {
|
||||
pub title: String,
|
||||
pub video_id: String,
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QPlaylistCont {
|
||||
context: ContextYT,
|
||||
continuation: String,
|
||||
}
|
||||
|
||||
impl RustyTube {
|
||||
|
|
@ -46,6 +45,51 @@ impl RustyTube {
|
|||
|
||||
map_playlist(&playlist_response)
|
||||
}
|
||||
|
||||
pub async fn get_playlist_cont(&self, playlist: &mut Playlist) -> Result<()> {
|
||||
match &playlist.ctoken {
|
||||
Some(ctoken) => {
|
||||
let client = self.get_ytclient(ClientType::Desktop);
|
||||
let context = client.get_context(true).await;
|
||||
|
||||
let request_body = QPlaylistCont {
|
||||
context,
|
||||
continuation: ctoken.to_owned(),
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.request_builder(Method::POST, "browse")
|
||||
.await
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
|
||||
let cont_response = resp.json::<response::playlist::PlaylistCont>().await?;
|
||||
|
||||
let action = some_or_bail!(
|
||||
cont_response
|
||||
.on_response_received_actions
|
||||
.iter()
|
||||
.find(|a| a.append_continuation_items_action.target_id == playlist.id),
|
||||
Err(anyhow!("no continuation action"))
|
||||
);
|
||||
|
||||
let (mut videos, ctoken) =
|
||||
map_playlist_items(&action.append_continuation_items_action.continuation_items);
|
||||
|
||||
playlist.videos.append(&mut videos);
|
||||
playlist.ctoken = ctoken;
|
||||
|
||||
if playlist.ctoken.is_none() {
|
||||
playlist.n_videos = playlist.videos.len() as u32;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
None => Err(anyhow!("no ctoken")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_playlist(response: &response::Playlist) -> Result<Playlist> {
|
||||
|
|
@ -74,49 +118,7 @@ fn map_playlist(response: &response::Playlist) -> Result<Playlist> {
|
|||
.playlist_video_list_renderer
|
||||
.contents;
|
||||
|
||||
let mut ctoken: Option<String> = None;
|
||||
let videos = video_items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
response::playlist::PlaylistVideoItem::PlaylistVideoRenderer { video } => {
|
||||
match &video.channel {
|
||||
TextLink::Browse {
|
||||
text,
|
||||
page_type,
|
||||
browse_id,
|
||||
} => match page_type {
|
||||
PageType::Channel => Some(Video {
|
||||
id: video.video_id.to_owned(),
|
||||
title: video.title.to_owned(),
|
||||
length: video.length_seconds,
|
||||
thumbnails: video
|
||||
.thumbnail
|
||||
.thumbnails
|
||||
.iter()
|
||||
.map(|t| Thumbnail {
|
||||
url: t.url.to_owned(),
|
||||
width: t.width,
|
||||
height: t.height,
|
||||
})
|
||||
.collect(),
|
||||
channel: Channel {
|
||||
id: browse_id.to_string(),
|
||||
name: text.to_owned(),
|
||||
},
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
response::playlist::PlaylistVideoItem::ContinuationItemRenderer {
|
||||
continuation_endpoint,
|
||||
} => {
|
||||
ctoken = Some(continuation_endpoint.continuation_command.token.to_owned());
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let (videos, ctoken) = map_playlist_items(video_items);
|
||||
|
||||
let thumbnail_renderer = some_or_bail!(
|
||||
response
|
||||
|
|
@ -151,7 +153,7 @@ fn map_playlist(response: &response::Playlist) -> Result<Playlist> {
|
|||
match &response.header.playlist_header_renderer.num_videos_text {
|
||||
Text::Multiple { runs } =>
|
||||
if runs.len() == 2 && runs[1] == " videos" {
|
||||
runs[0].parse().ok()
|
||||
runs[0].replace(",", "").replace(".", "").parse().ok()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
|
|
@ -175,6 +177,11 @@ fn map_playlist(response: &response::Playlist) -> Result<Playlist> {
|
|||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let id = response
|
||||
.header
|
||||
.playlist_header_renderer
|
||||
.playlist_id
|
||||
.to_owned();
|
||||
let name = response.header.playlist_header_renderer.title.to_owned();
|
||||
let description = response
|
||||
.header
|
||||
|
|
@ -201,16 +208,65 @@ fn map_playlist(response: &response::Playlist) -> Result<Playlist> {
|
|||
};
|
||||
|
||||
Ok(Playlist {
|
||||
id,
|
||||
name,
|
||||
videos,
|
||||
n_videos,
|
||||
ctoken,
|
||||
name,
|
||||
thumbnails,
|
||||
description,
|
||||
channel,
|
||||
last_update: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_playlist_items(
|
||||
items: &Vec<response::VideoListItem<response::playlist::PlaylistVideo>>,
|
||||
) -> (Vec<Video>, Option<String>) {
|
||||
let mut ctoken: Option<String> = None;
|
||||
let videos = items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
response::VideoListItem::GridVideoRenderer { video } => match &video.channel {
|
||||
TextLink::Browse {
|
||||
text,
|
||||
page_type,
|
||||
browse_id,
|
||||
} => match page_type {
|
||||
PageType::Channel => Some(Video {
|
||||
id: video.video_id.to_owned(),
|
||||
title: video.title.to_owned(),
|
||||
length: video.length_seconds,
|
||||
thumbnails: video
|
||||
.thumbnail
|
||||
.thumbnails
|
||||
.iter()
|
||||
.map(|t| Thumbnail {
|
||||
url: t.url.to_owned(),
|
||||
width: t.width,
|
||||
height: t.height,
|
||||
})
|
||||
.collect(),
|
||||
channel: Channel {
|
||||
id: browse_id.to_string(),
|
||||
name: text.to_owned(),
|
||||
},
|
||||
}),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
},
|
||||
response::VideoListItem::ContinuationItemRenderer {
|
||||
continuation_endpoint,
|
||||
} => {
|
||||
ctoken = Some(continuation_endpoint.continuation_command.token.to_owned());
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(videos, ctoken)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{fs::File, io::BufReader, path::Path};
|
||||
|
|
@ -299,6 +355,7 @@ mod tests {
|
|||
let rt = RustyTube::new();
|
||||
let playlist = rt.get_playlist(id).await.unwrap();
|
||||
|
||||
assert_eq!(playlist.id, id);
|
||||
assert_eq!(playlist.name, name);
|
||||
assert!(!playlist.videos.is_empty());
|
||||
assert_eq!(playlist.ctoken.is_some(), is_long);
|
||||
|
|
@ -323,4 +380,19 @@ mod tests {
|
|||
let playlist_data = map_playlist(&playlist).unwrap();
|
||||
insta::assert_yaml_snapshot!(format!("map_playlist_data_{}", name), playlist_data);
|
||||
}
|
||||
|
||||
#[test_log::test(tokio::test)]
|
||||
async fn t_playlist_cont() {
|
||||
let rt = RustyTube::new();
|
||||
let mut playlist = rt
|
||||
.get_playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
while playlist.ctoken.is_some() {
|
||||
rt.get_playlist_cont(&mut playlist).await.unwrap();
|
||||
}
|
||||
|
||||
assert!(playlist.videos.len() > 100);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
87
src/client/response/channel.rs
Normal file
87
src/client/response/channel.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
use serde::Deserialize;
|
||||
use serde_with::serde_as;
|
||||
use serde_with::VecSkipError;
|
||||
|
||||
use super::{ContentRenderer, ContentsRenderer, Thumbnails, VideoListItem};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Channel {
|
||||
pub contents: Contents,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Contents {
|
||||
pub two_column_browse_results_renderer: TabsRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TabsRenderer {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub tabs: Vec<TabRendererWrap>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TabRendererWrap {
|
||||
pub tab_renderer: ContentRenderer<SectionListRendererWrap>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SectionListRendererWrap {
|
||||
pub section_list_renderer: ContentsRenderer<ItemSectionRendererWrap>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ItemSectionRendererWrap {
|
||||
pub item_section_renderer: ContentsRenderer<GridRendererWrap>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GridRendererWrap {
|
||||
pub grid_renderer: GridRenderer,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GridRenderer {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub items: Vec<VideoListItem<ChannelVideo>>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChannelVideo {
|
||||
pub video_id: String,
|
||||
pub thumbnail: Thumbnails,
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
pub title: String,
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
pub published_time_text: String,
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
pub view_count_text: String,
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub thumbnail_overlays: Vec<TimeOverlayWrap>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TimeOverlayWrap {
|
||||
pub thumbnail_overlay_time_status_renderer: TimeOverlay,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TimeOverlay {
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
pub text: String,
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
pub mod channel;
|
||||
pub mod player;
|
||||
pub mod playlist;
|
||||
pub mod playlist_music;
|
||||
|
||||
pub use channel::Channel;
|
||||
pub use player::Player;
|
||||
pub use playlist::Playlist;
|
||||
pub use playlist_music::PlaylistMusic;
|
||||
|
|
@ -44,10 +46,19 @@ pub struct Thumbnail {
|
|||
pub height: u32,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContinuationItemRenderer {
|
||||
pub continuation_endpoint: ContinuationEndpoint,
|
||||
pub enum VideoListItem<T> {
|
||||
#[serde(alias = "playlistVideoRenderer")]
|
||||
GridVideoRenderer {
|
||||
#[serde(flatten)]
|
||||
video: T,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ContinuationItemRenderer {
|
||||
continuation_endpoint: ContinuationEndpoint,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -24,27 +24,15 @@ pub enum PlayabilityStatus {
|
|||
Ok { live_streamability: Option<Empty> },
|
||||
/// Video cant be played because of DRM / Geoblock
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Unplayable {
|
||||
reason: String,
|
||||
// error_screen: Option<ErrorScreen>,
|
||||
},
|
||||
Unplayable { reason: String },
|
||||
/// Age limit / Private video
|
||||
#[serde(rename_all = "camelCase")]
|
||||
LoginRequired {
|
||||
reason: String,
|
||||
// error_screen: Option<ErrorScreen>
|
||||
},
|
||||
LoginRequired { reason: String },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
LiveStreamOffline {
|
||||
reason: String,
|
||||
// error_screen: Option<ErrorScreen>
|
||||
},
|
||||
LiveStreamOffline { reason: String },
|
||||
/// Video was censored / deleted
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Error {
|
||||
reason: String,
|
||||
// error_screen: Option<ErrorScreen>
|
||||
},
|
||||
Error { reason: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use serde_with::{json::JsonString, DefaultOnError, VecSkipError};
|
|||
|
||||
use crate::serializer::text::{Text, TextLink};
|
||||
|
||||
use super::{ContentRenderer, ContentsRenderer, ContinuationEndpoint, Thumbnails, ThumbnailsWrap};
|
||||
use super::{ContentRenderer, ContentsRenderer, Thumbnails, ThumbnailsWrap, VideoListItem};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -14,6 +14,14 @@ pub struct Playlist {
|
|||
pub sidebar: Sidebar,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaylistCont {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub on_response_received_actions: Vec<OnResponseReceivedAction>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Contents {
|
||||
|
|
@ -49,21 +57,7 @@ pub struct PlaylistVideoListRenderer {
|
|||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaylistVideoList {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub contents: Vec<PlaylistVideoItem>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PlaylistVideoItem {
|
||||
PlaylistVideoRenderer {
|
||||
#[serde(flatten)]
|
||||
video: PlaylistVideo,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
ContinuationItemRenderer {
|
||||
continuation_endpoint: ContinuationEndpoint,
|
||||
},
|
||||
pub contents: Vec<VideoListItem<PlaylistVideo>>,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
|
|
@ -152,3 +146,18 @@ pub struct VideoOwner {
|
|||
#[serde_as(as = "crate::serializer::text::TextLink")]
|
||||
pub title: TextLink,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OnResponseReceivedAction {
|
||||
pub append_continuation_items_action: AppendAction,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppendAction {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
pub continuation_items: Vec<VideoListItem<PlaylistVideo>>,
|
||||
pub target_id: String,
|
||||
}
|
||||
|
|
|
|||
280
src/client/scripts/language_menu.rs
Normal file
280
src/client/scripts/language_menu.rs
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
#![cfg(test)]
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use fancy_regex::Regex;
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::serde_as;
|
||||
use serde_with::VecSkipError;
|
||||
|
||||
use crate::client::ClientType;
|
||||
use crate::client::ContextYT;
|
||||
use crate::client::RustyTube;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QLanguageMenu {
|
||||
context: ContextYT,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LanguageMenu {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
actions: Vec<ActionWrap>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ActionWrap {
|
||||
open_popup_action: OpenPopupAction,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OpenPopupAction {
|
||||
popup: Popup,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Popup {
|
||||
multi_page_menu_renderer: MultiPageMenuRenderer<MenuSectionRenderer>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MultiPageMenuRenderer<T> {
|
||||
sections: Vec<MenuSectionRendererWrap<T>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MenuSectionRendererWrap<T> {
|
||||
multi_page_menu_section_renderer: T,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MenuSectionRenderer {
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
items: Vec<CompactLinkRendererWrap>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CompactLinkRendererWrap {
|
||||
compact_link_renderer: CompactLinkRenderer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CompactLinkRenderer {
|
||||
icon: Icon,
|
||||
service_endpoint: ServiceEndpoint<MenuAction>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Icon {
|
||||
icon_type: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ServiceEndpoint<T> {
|
||||
signal_service_endpoint: SignalServiceEndpoint<T>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SignalServiceEndpoint<T> {
|
||||
actions: Vec<T>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MenuAction {
|
||||
get_multi_page_menu_action: MultiPageMenuAction,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MultiPageMenuAction {
|
||||
menu: Menu,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Menu {
|
||||
multi_page_menu_renderer: MultiPageMenuRenderer<ItemSectionRenderer>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ItemSectionRenderer {
|
||||
items: Vec<LanguageItemWrap>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LanguageItemWrap {
|
||||
compact_link_renderer: LanguageItem,
|
||||
}
|
||||
|
||||
#[serde_as]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LanguageItem {
|
||||
#[serde_as(as = "crate::serializer::text::Text")]
|
||||
title: String,
|
||||
service_endpoint: ServiceEndpoint<LanguageCountryAction>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LanguageCountryAction {
|
||||
#[serde(alias = "selectCountryCommand")]
|
||||
select_language_command: LanguageCountryCommand,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct LanguageCountryCommand {
|
||||
#[serde(alias = "gl")]
|
||||
hl: String,
|
||||
}
|
||||
|
||||
#[test_log::test(tokio::test)]
|
||||
async fn generate_locales() {
|
||||
let (languages, countries) = get_locales().await;
|
||||
|
||||
let mut code = "// GENERATED SECTION START //\n".to_owned();
|
||||
|
||||
code.push_str("#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]\n");
|
||||
code.push_str("#[serde(rename_all = \"kebab-case\")]\n");
|
||||
code.push_str("pub enum Language {\n");
|
||||
|
||||
languages.iter().for_each(|(c, n)| {
|
||||
code.push_str(&format!(" /// {}\n ", n));
|
||||
|
||||
if c.contains('-') {
|
||||
code.push_str(&format!("#[serde(rename=\"{}\")]\n ", c));
|
||||
}
|
||||
|
||||
c.split('-').for_each(|c| {
|
||||
code.push_str(&format!(
|
||||
"{}{}",
|
||||
c[0..1].to_owned().to_uppercase(),
|
||||
c[1..].to_owned().to_lowercase()
|
||||
))
|
||||
});
|
||||
code.push_str(",\n");
|
||||
});
|
||||
|
||||
code.push_str("}\n\n");
|
||||
|
||||
code.push_str("#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]\n");
|
||||
code.push_str("#[serde(rename_all = \"SCREAMING_SNAKE_CASE\")]\n");
|
||||
code.push_str("pub enum Country {\n");
|
||||
|
||||
countries.iter().for_each(|(c, n)| {
|
||||
code.push_str(&format!(" /// {}\n", n));
|
||||
code.push_str(&format!(
|
||||
" {}{},\n",
|
||||
c[0..1].to_owned().to_uppercase(),
|
||||
c[1..].to_owned().to_lowercase()
|
||||
))
|
||||
});
|
||||
|
||||
code.push_str("}\n");
|
||||
|
||||
code.push_str("// GENERATED SECTION END //");
|
||||
|
||||
let locale_path = Path::new("src/model/locale.rs");
|
||||
let src = std::fs::read_to_string(locale_path).unwrap();
|
||||
|
||||
let delim_pattern =
|
||||
Regex::new("// GENERATED SECTION START //\n[^@]*// GENERATED SECTION END //").unwrap();
|
||||
|
||||
let new_src = delim_pattern.replace(&src, code);
|
||||
std::fs::write(locale_path, new_src.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
async fn get_locales() -> (BTreeMap<String, String>, BTreeMap<String, String>) {
|
||||
let rt = RustyTube::new();
|
||||
let client = rt.get_ytclient(ClientType::Desktop);
|
||||
let context = client.get_context(true).await;
|
||||
|
||||
let request_body = QLanguageMenu { context };
|
||||
|
||||
let resp = client
|
||||
.request_builder(Method::POST, "account/account_menu")
|
||||
.await
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
|
||||
let language_menu = resp.json::<LanguageMenu>().await.unwrap();
|
||||
|
||||
let lm_section = &language_menu.actions[0]
|
||||
.open_popup_action
|
||||
.popup
|
||||
.multi_page_menu_renderer
|
||||
.sections
|
||||
.iter()
|
||||
.find(|s| s.multi_page_menu_section_renderer.items.len() >= 2)
|
||||
.unwrap();
|
||||
|
||||
let lang_section = lm_section
|
||||
.multi_page_menu_section_renderer
|
||||
.items
|
||||
.iter()
|
||||
.find(|s| s.compact_link_renderer.icon.icon_type == "TRANSLATE")
|
||||
.unwrap();
|
||||
|
||||
let country_section = lm_section
|
||||
.multi_page_menu_section_renderer
|
||||
.items
|
||||
.iter()
|
||||
.find(|s| s.compact_link_renderer.icon.icon_type == "LANGUAGE")
|
||||
.unwrap();
|
||||
|
||||
let languages = map_language_section(lang_section);
|
||||
let countries = map_language_section(country_section);
|
||||
|
||||
(languages, countries)
|
||||
}
|
||||
|
||||
fn map_language_section(section: &CompactLinkRendererWrap) -> BTreeMap<String, String> {
|
||||
section
|
||||
.compact_link_renderer
|
||||
.service_endpoint
|
||||
.signal_service_endpoint
|
||||
.actions[0]
|
||||
.get_multi_page_menu_action
|
||||
.menu
|
||||
.multi_page_menu_renderer
|
||||
.sections[0]
|
||||
.multi_page_menu_section_renderer
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| {
|
||||
(
|
||||
i.compact_link_renderer
|
||||
.service_endpoint
|
||||
.signal_service_endpoint
|
||||
.actions[0]
|
||||
.select_language_command
|
||||
.hl
|
||||
.to_owned(),
|
||||
i.compact_link_renderer.title.to_owned(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
}
|
||||
3
src/client/scripts/mod.rs
Normal file
3
src/client/scripts/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
#![cfg(test)]
|
||||
mod language_menu;
|
||||
mod timeago_testfiles;
|
||||
130
src/client/scripts/timeago_testfiles.rs
Normal file
130
src/client/scripts/timeago_testfiles.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
#![cfg(test)]
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
fs::File,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use futures::{stream, StreamExt};
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
client::{response, ClientType, ContextYT, RustyTube},
|
||||
model::{Country, Language},
|
||||
timeago,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QChannel {
|
||||
context: ContextYT,
|
||||
browse_id: String,
|
||||
params: String,
|
||||
}
|
||||
|
||||
async fn get_channel_datestrings(rp: &RustyTube, channel_id: &str) -> Vec<String> {
|
||||
let client = rp.get_ytclient(ClientType::Desktop);
|
||||
let context = client.get_context(true).await;
|
||||
|
||||
let request_body = QChannel {
|
||||
context,
|
||||
browse_id: channel_id.to_owned(),
|
||||
params: "EgZ2aWRlb3PyBgQKAjoA".to_owned(),
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.request_builder(Method::POST, "browse")
|
||||
.await
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
|
||||
let channel_response = resp.json::<response::Channel>().await.unwrap();
|
||||
|
||||
channel_response
|
||||
.contents
|
||||
.two_column_browse_results_renderer
|
||||
.tabs[0]
|
||||
.tab_renderer
|
||||
.content
|
||||
.section_list_renderer
|
||||
.contents[0]
|
||||
.item_section_renderer
|
||||
.contents[0]
|
||||
.grid_renderer
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|itm| match itm {
|
||||
response::VideoListItem::GridVideoRenderer { video } => {
|
||||
Some(video.published_time_text.to_owned())
|
||||
}
|
||||
response::VideoListItem::ContinuationItemRenderer { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
#[test_log::test(tokio::test)]
|
||||
async fn download_timeago_testfiles() {
|
||||
let json_path = Path::new("testfiles/date/timeago.json").to_path_buf();
|
||||
if json_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let channel_ids = [
|
||||
"UCeY0bbntWzzVIaj2z3QigXg",
|
||||
"UCcmpeVbSSQlZRvHfdC-CRwg",
|
||||
"UC65afEgL62PGFWXY7n6CUbA",
|
||||
"UCEOXxzW2vU0P-0THehuIIeg",
|
||||
];
|
||||
|
||||
// Get strings of all languages
|
||||
let mut lang_strings: BTreeMap<Language, Vec<String>> = BTreeMap::new();
|
||||
for lang in timeago::LANGUAGES {
|
||||
let rp = RustyTube::new_with_ua(lang, Country::Us, None);
|
||||
let strings = stream::iter(channel_ids)
|
||||
.map(|id| get_channel_datestrings(&rp, id))
|
||||
.buffered(4)
|
||||
.collect::<Vec<_>>()
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
lang_strings.insert(lang, strings);
|
||||
}
|
||||
|
||||
let mut en_strings_uniq: HashSet<&str> = HashSet::new();
|
||||
let mut uniq_ids: HashSet<usize> = HashSet::new();
|
||||
|
||||
lang_strings[&Language::En]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.for_each(|(n, s)| {
|
||||
if en_strings_uniq.insert(s) {
|
||||
uniq_ids.insert(n);
|
||||
}
|
||||
});
|
||||
|
||||
let strings_map = lang_strings
|
||||
.iter()
|
||||
.map(|(lang, strings)| {
|
||||
(
|
||||
lang,
|
||||
strings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(n, _)| uniq_ids.contains(n))
|
||||
.map(|(_, s)| s)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
let file = File::create(json_path).unwrap();
|
||||
serde_json::to_writer_pretty(file, &strings_map).unwrap();
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
source: src/client/playlist.rs
|
||||
expression: playlist_data
|
||||
---
|
||||
id: PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ
|
||||
name: Die schönsten deutschen Lieder | Beliebteste Lieder | Beste Deutsche Musik 2022
|
||||
videos:
|
||||
- id: Bkj3IVIO2Os
|
||||
title: Stereoact feat. Kerstin Ott - Die Immer Lacht (Official Video HD)
|
||||
|
|
@ -1905,7 +1907,6 @@ videos:
|
|||
name: KMNGANG
|
||||
n_videos: 495
|
||||
ctoken: 4qmFsgJhEiRWTFBMNWREeDY4MVQ0YlI3WkYxSXVXek92MW9tbFJiRTdQaUoaFENBRjZCbEJVT2tOSFdRJTNEJTNEmgIiUEw1ZER4NjgxVDRiUjdaRjFJdVd6T3Yxb21sUmJFN1BpSg%3D%3D
|
||||
name: Die schönsten deutschen Lieder | Beliebteste Lieder | Beste Deutsche Musik 2022
|
||||
thumbnails:
|
||||
- url: "https://i.ytimg.com/vi/Bkj3IVIO2Os/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLBShlXfy9oWvTy2ntoHxmuhBlZP3g"
|
||||
width: 168
|
||||
|
|
@ -1923,4 +1924,5 @@ description: ~
|
|||
channel:
|
||||
id: UCIekuFeMaV78xYfvpmoCnPg
|
||||
name: Best Music
|
||||
last_update: ~
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
source: src/client/playlist.rs
|
||||
expression: playlist_data
|
||||
---
|
||||
id: PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe
|
||||
name: Minecraft SHINE
|
||||
videos:
|
||||
- id: X82TrticM4A
|
||||
title: Minecraft SHINE (Trailer)
|
||||
|
|
@ -1259,7 +1261,6 @@ videos:
|
|||
name: Chaosflo44
|
||||
n_videos: 66
|
||||
ctoken: ~
|
||||
name: Minecraft SHINE
|
||||
thumbnails:
|
||||
- url: "https://i.ytimg.com/vi/X82TrticM4A/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLBeSNDZEaBoP0KX9_Ayn3VO3X8rkw"
|
||||
width: 168
|
||||
|
|
@ -1277,4 +1278,5 @@ description: "SHINE - Survival Hardcore in New Environment: Auf einem Server mac
|
|||
channel:
|
||||
id: UCQM0bS4_04-Y4JuYrgmnpZQ
|
||||
name: Chaosflo44
|
||||
last_update: ~
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
source: src/client/playlist.rs
|
||||
expression: playlist_data
|
||||
---
|
||||
id: RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk
|
||||
name: Easy Pop
|
||||
videos:
|
||||
- id: psuRGfAaju4
|
||||
title: Owl City - Fireflies (Official Music Video)
|
||||
|
|
@ -1848,7 +1850,6 @@ videos:
|
|||
name: Diaven - Topic
|
||||
n_videos: 97
|
||||
ctoken: ~
|
||||
name: Easy Pop
|
||||
thumbnails:
|
||||
- url: "https://i9.ytimg.com/s_p/RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk/mqdefault.jpg?sqp=CPDmr5gGir7X7AMGCLaHmpgG&rs=AOn4CLDg9xLeJDPeMtbWfp19VFd6vCQzqQ&v=1661371318"
|
||||
width: 180
|
||||
|
|
@ -1861,4 +1862,5 @@ thumbnails:
|
|||
height: 1200
|
||||
description: ~
|
||||
channel: ~
|
||||
last_update: ~
|
||||
|
||||
|
|
|
|||
Reference in a new issue