feat: add timeago parser, playlist_cont

This commit is contained in:
ThetaDev 2022-09-01 20:13:50 +02:00
parent 5b8c3d646a
commit 346406c1c8
25 changed files with 11374 additions and 183 deletions

View file

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

View file

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

View file

@ -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);
}
}

View 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,
}

View file

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

View file

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

View file

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

View 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<_, _>>()
}

View file

@ -0,0 +1,3 @@
#![cfg(test)]
mod language_menu;
mod timeago_testfiles;

View 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();
}

View file

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

View file

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

View file

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