feat: add timeago parser, playlist_cont
This commit is contained in:
parent
5b8c3d646a
commit
346406c1c8
25 changed files with 11374 additions and 183 deletions
|
|
@ -59,7 +59,7 @@ async fn download_single_video(
|
||||||
|
|
||||||
let res = async {
|
let res = async {
|
||||||
let player_data = rt
|
let player_data = rt
|
||||||
.get_player(video_id.as_str(), ClientType::Desktop)
|
.get_player(video_id.as_str(), ClientType::TvHtml5Embed)
|
||||||
.await
|
.await
|
||||||
.context(format!(
|
.context(format!(
|
||||||
"Failed to fetch player data for video {}",
|
"Failed to fetch player data for video {}",
|
||||||
|
|
|
||||||
5699
notes/language_menu.json
Normal file
5699
notes/language_menu.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -26,3 +26,25 @@ Throttling issue: Y8JFxS1HlDo
|
||||||
495 Songs: PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ
|
495 Songs: PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ
|
||||||
78 Videos: PL2_OBreMn7FpDFj9lWfoZ8OQJvZkQa3yG
|
78 Videos: PL2_OBreMn7FpDFj9lWfoZ8OQJvZkQa3yG
|
||||||
66 Videos: PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe
|
66 Videos: PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe
|
||||||
|
4.657 Songs: PLI_eFW8NAFzYAXZ5DrU6E6mQ_XfhaLBUX
|
||||||
|
186 Songs: PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi
|
||||||
|
|
||||||
|
Playlist update dates:
|
||||||
|
today: RDCLAK5uy_kj3rhiar1LINmyDcuFnXihEO0K1NQa2jI
|
||||||
|
yesterday: PL3-sRm8xAzY9sDilvaWjCwCI0TkUzYdOG
|
||||||
|
2 days ago: PL3qHjxSSl7AER3rxfEr4SiHNr-ihbQyqU
|
||||||
|
3 days ago: PLHr0jWPfopte182N54r1ra7tkRJC1fmPu
|
||||||
|
5 days ago: PLF7B92F492FDAE703
|
||||||
|
|
||||||
|
Jan PL1J-6JOckZtHxTA3hN5SK7gBQaFfKzeXr 01.01.2016
|
||||||
|
Feb PL1J-6JOckZtETrbzwZE7mRIIK6BzWNLAs 07.02.2016
|
||||||
|
Mar PL1J-6JOckZtG3AVdvBXhMO64mB2k3BtKi 09.03.2015
|
||||||
|
Apr PL1J-6JOckZtE_rUpK24S6X5hOE4eQoprN 02.04.2017
|
||||||
|
May PL1J-6JOckZtG1ThBxoSLFL-Jg4sa2iX_a 22.05.2014
|
||||||
|
Jun PL1J-6JOckZtF_wSzkXBl91pit9d6Fh0QF 28.07.2014
|
||||||
|
Jul PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe 02.07.2014
|
||||||
|
Aug PL1J-6JOckZtFFQeWx-ZC0ubpJCEWmGWRx 23.08.2015
|
||||||
|
Sep PL1J-6JOckZtHVs0JhBW_qfsW-dtXuM0mQ 16.09.2018
|
||||||
|
Oct PL1J-6JOckZtE4g-XgZkL_N0kkoKui5Eys 31.10.2014
|
||||||
|
Nov PL1J-6JOckZtEzjMUEyPyPpG836pjeIapw 03.11.2016
|
||||||
|
Dec PL1J-6JOckZtHo91uApeb10Qlf2XhkfM-9 24.12.2021
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ pub mod player;
|
||||||
pub mod playlist;
|
pub mod playlist;
|
||||||
mod response;
|
mod response;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod scripts;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
|
@ -15,7 +18,7 @@ use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
cache::{Cache, ClientData},
|
cache::{Cache, ClientData},
|
||||||
model::Locale,
|
model::{Country, Language},
|
||||||
util,
|
util,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -68,10 +71,8 @@ struct ClientInfo {
|
||||||
platform: String,
|
platform: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
original_url: Option<String>,
|
original_url: Option<String>,
|
||||||
/// Language (`en`, `de`)
|
hl: Language,
|
||||||
hl: String,
|
gl: Country,
|
||||||
/// Country (`US`, `DE`)
|
|
||||||
gl: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[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_API_KEY: &str = "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc";
|
||||||
const IOS_DEVICE_MODEL: &str = "iPhone14,5";
|
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(),
|
||||||
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 struct RustyTube {
|
||||||
pub locale: Arc<Locale>,
|
localization: Arc<Localization>,
|
||||||
cache: Cache,
|
cache: Cache,
|
||||||
desktop_client: Arc<DesktopClient>,
|
desktop_client: Arc<DesktopClient>,
|
||||||
desktop_music_client: Arc<DesktopMusicClient>,
|
desktop_music_client: Arc<DesktopMusicClient>,
|
||||||
|
|
@ -145,17 +146,30 @@ pub struct RustyTube {
|
||||||
tvhtml5embed_client: Arc<TvHtml5EmbedClient>,
|
tvhtml5embed_client: Arc<TvHtml5EmbedClient>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct Localization {
|
||||||
|
language: Language,
|
||||||
|
content_country: Country,
|
||||||
|
}
|
||||||
|
|
||||||
impl RustyTube {
|
impl RustyTube {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
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]
|
#[must_use]
|
||||||
pub fn new_with_ua(lang: &str, country: &str, cache_file: Option<String>) -> Self {
|
pub fn new_with_ua(
|
||||||
let locale = Arc::new(Locale {
|
language: Language,
|
||||||
lang: lang.to_owned(),
|
content_country: Country,
|
||||||
country: country.to_owned(),
|
cache_file: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
let loc = Arc::new(Localization {
|
||||||
|
language,
|
||||||
|
content_country,
|
||||||
});
|
});
|
||||||
|
|
||||||
let cache = match cache_file.as_ref() {
|
let cache = match cache_file.as_ref() {
|
||||||
|
|
@ -164,13 +178,13 @@ impl RustyTube {
|
||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
locale: locale.clone(),
|
localization: loc.clone(),
|
||||||
cache: cache.clone(),
|
cache: cache.clone(),
|
||||||
desktop_client: Arc::new(DesktopClient::new(locale.clone(), cache.clone())),
|
desktop_client: Arc::new(DesktopClient::new(loc.clone(), cache.clone())),
|
||||||
desktop_music_client: Arc::new(DesktopMusicClient::new(locale.clone(), cache)),
|
desktop_music_client: Arc::new(DesktopMusicClient::new(loc.clone(), cache)),
|
||||||
android_client: Arc::new(AndroidClient::new(locale.clone())),
|
android_client: Arc::new(AndroidClient::new(loc.clone())),
|
||||||
ios_client: Arc::new(IosClient::new(locale.clone())),
|
ios_client: Arc::new(IosClient::new(loc.clone())),
|
||||||
tvhtml5embed_client: Arc::new(TvHtml5EmbedClient::new(locale)),
|
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 {
|
pub struct DesktopClient {
|
||||||
locale: Arc<Locale>,
|
localization: Arc<Localization>,
|
||||||
http: Client,
|
http: Client,
|
||||||
cache: Cache,
|
cache: Cache,
|
||||||
consent_cookie: String,
|
consent_cookie: String,
|
||||||
|
|
@ -220,12 +234,12 @@ impl YTClient for DesktopClient {
|
||||||
platform: "DESKTOP".to_owned(),
|
platform: "DESKTOP".to_owned(),
|
||||||
original_url: Some("https://www.youtube.com/".to_owned()),
|
original_url: Some("https://www.youtube.com/".to_owned()),
|
||||||
hl: match localized {
|
hl: match localized {
|
||||||
true => self.locale.lang.to_owned(),
|
true => self.localization.language,
|
||||||
false => "en".to_owned(),
|
false => Language::En,
|
||||||
},
|
},
|
||||||
gl: match localized {
|
gl: match localized {
|
||||||
true => self.locale.country.to_owned(),
|
true => self.localization.content_country,
|
||||||
false => "US".to_owned(),
|
false => Country::Us,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
request: Some(RequestYT::default()),
|
request: Some(RequestYT::default()),
|
||||||
|
|
@ -260,7 +274,7 @@ impl YTClient for DesktopClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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 mut rng = rand::thread_rng();
|
||||||
|
|
||||||
let http = ClientBuilder::new()
|
let http = ClientBuilder::new()
|
||||||
|
|
@ -271,7 +285,7 @@ impl DesktopClient {
|
||||||
.expect("unable to build the HTTP client");
|
.expect("unable to build the HTTP client");
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
locale,
|
localization,
|
||||||
http,
|
http,
|
||||||
cache,
|
cache,
|
||||||
consent_cookie: format!(
|
consent_cookie: format!(
|
||||||
|
|
@ -329,7 +343,7 @@ impl DesktopClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AndroidClient {
|
pub struct AndroidClient {
|
||||||
locale: Arc<Locale>,
|
localization: Arc<Localization>,
|
||||||
http: Client,
|
http: Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -345,12 +359,12 @@ impl YTClient for AndroidClient {
|
||||||
platform: "MOBILE".to_owned(),
|
platform: "MOBILE".to_owned(),
|
||||||
original_url: None,
|
original_url: None,
|
||||||
hl: match localized {
|
hl: match localized {
|
||||||
true => self.locale.lang.to_owned(),
|
true => self.localization.language,
|
||||||
false => "en".to_owned(),
|
false => Language::En,
|
||||||
},
|
},
|
||||||
gl: match localized {
|
gl: match localized {
|
||||||
true => self.locale.country.to_owned(),
|
true => self.localization.content_country,
|
||||||
false => "US".to_owned(),
|
false => Country::Us,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
request: None,
|
request: None,
|
||||||
|
|
@ -384,22 +398,22 @@ impl YTClient for AndroidClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AndroidClient {
|
impl AndroidClient {
|
||||||
fn new(locale: Arc<Locale>) -> Self {
|
fn new(localization: Arc<Localization>) -> Self {
|
||||||
let http = ClientBuilder::new()
|
let http = ClientBuilder::new()
|
||||||
.user_agent(format!(
|
.user_agent(format!(
|
||||||
"com.google.android.youtube/{} (Linux; U; Android 12; {}) gzip",
|
"com.google.android.youtube/{} (Linux; U; Android 12; {}) gzip",
|
||||||
MOBILE_CLIENT_VERSION, locale.country
|
MOBILE_CLIENT_VERSION, localization.content_country
|
||||||
))
|
))
|
||||||
.gzip(true)
|
.gzip(true)
|
||||||
.build()
|
.build()
|
||||||
.expect("unable to build the HTTP client");
|
.expect("unable to build the HTTP client");
|
||||||
|
|
||||||
Self { locale, http }
|
Self { localization, http }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct IosClient {
|
pub struct IosClient {
|
||||||
locale: Arc<Locale>,
|
localization: Arc<Localization>,
|
||||||
http: Client,
|
http: Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -415,12 +429,12 @@ impl YTClient for IosClient {
|
||||||
platform: "MOBILE".to_owned(),
|
platform: "MOBILE".to_owned(),
|
||||||
original_url: None,
|
original_url: None,
|
||||||
hl: match localized {
|
hl: match localized {
|
||||||
true => self.locale.lang.to_owned(),
|
true => self.localization.language,
|
||||||
false => "en".to_owned(),
|
false => Language::En,
|
||||||
},
|
},
|
||||||
gl: match localized {
|
gl: match localized {
|
||||||
true => self.locale.country.to_owned(),
|
true => self.localization.content_country,
|
||||||
false => "US".to_owned(),
|
false => Country::Us,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
request: None,
|
request: None,
|
||||||
|
|
@ -451,22 +465,22 @@ impl YTClient for IosClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IosClient {
|
impl IosClient {
|
||||||
fn new(locale: Arc<Locale>) -> Self {
|
fn new(localization: Arc<Localization>) -> Self {
|
||||||
let http = ClientBuilder::new()
|
let http = ClientBuilder::new()
|
||||||
.user_agent(format!(
|
.user_agent(format!(
|
||||||
"com.google.ios.youtube/{} ({}; U; CPU iOS 15_4 like Mac OS X; {})",
|
"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)
|
.gzip(true)
|
||||||
.build()
|
.build()
|
||||||
.expect("unable to build the HTTP client");
|
.expect("unable to build the HTTP client");
|
||||||
|
|
||||||
Self { locale, http }
|
Self { localization, http }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct TvHtml5EmbedClient {
|
pub struct TvHtml5EmbedClient {
|
||||||
locale: Arc<Locale>,
|
localization: Arc<Localization>,
|
||||||
http: Client,
|
http: Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -482,12 +496,12 @@ impl YTClient for TvHtml5EmbedClient {
|
||||||
platform: "TV".to_owned(),
|
platform: "TV".to_owned(),
|
||||||
original_url: None,
|
original_url: None,
|
||||||
hl: match localized {
|
hl: match localized {
|
||||||
true => self.locale.lang.to_owned(),
|
true => self.localization.language,
|
||||||
false => "en".to_owned(),
|
false => Language::En,
|
||||||
},
|
},
|
||||||
gl: match localized {
|
gl: match localized {
|
||||||
true => self.locale.country.to_owned(),
|
true => self.localization.content_country,
|
||||||
false => "US".to_owned(),
|
false => Country::Us,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
request: Some(RequestYT::default()),
|
request: Some(RequestYT::default()),
|
||||||
|
|
@ -523,7 +537,7 @@ impl YTClient for TvHtml5EmbedClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TvHtml5EmbedClient {
|
impl TvHtml5EmbedClient {
|
||||||
fn new(locale: Arc<Locale>) -> Self {
|
fn new(localization: Arc<Localization>) -> Self {
|
||||||
let http = ClientBuilder::new()
|
let http = ClientBuilder::new()
|
||||||
.user_agent(DEFAULT_UA)
|
.user_agent(DEFAULT_UA)
|
||||||
.gzip(true)
|
.gzip(true)
|
||||||
|
|
@ -531,12 +545,12 @@ impl TvHtml5EmbedClient {
|
||||||
.build()
|
.build()
|
||||||
.expect("unable to build the HTTP client");
|
.expect("unable to build the HTTP client");
|
||||||
|
|
||||||
Self { locale, http }
|
Self { localization, http }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DesktopMusicClient {
|
pub struct DesktopMusicClient {
|
||||||
locale: Arc<Locale>,
|
localization: Arc<Localization>,
|
||||||
http: Client,
|
http: Client,
|
||||||
cache: Cache,
|
cache: Cache,
|
||||||
consent_cookie: String,
|
consent_cookie: String,
|
||||||
|
|
@ -554,12 +568,12 @@ impl YTClient for DesktopMusicClient {
|
||||||
platform: "DESKTOP".to_owned(),
|
platform: "DESKTOP".to_owned(),
|
||||||
original_url: Some("https://music.youtube.com/".to_owned()),
|
original_url: Some("https://music.youtube.com/".to_owned()),
|
||||||
hl: match localized {
|
hl: match localized {
|
||||||
true => self.locale.lang.to_owned(),
|
true => self.localization.language,
|
||||||
false => "en".to_owned(),
|
false => Language::En,
|
||||||
},
|
},
|
||||||
gl: match localized {
|
gl: match localized {
|
||||||
true => self.locale.country.to_owned(),
|
true => self.localization.content_country,
|
||||||
false => "US".to_owned(),
|
false => Country::Us,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
request: Some(RequestYT::default()),
|
request: Some(RequestYT::default()),
|
||||||
|
|
@ -597,7 +611,7 @@ impl YTClient for DesktopMusicClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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 mut rng = rand::thread_rng();
|
||||||
|
|
||||||
let http = ClientBuilder::new()
|
let http = ClientBuilder::new()
|
||||||
|
|
@ -608,7 +622,7 @@ impl DesktopMusicClient {
|
||||||
.expect("unable to build the HTTP client");
|
.expect("unable to build the HTTP client");
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
locale,
|
localization,
|
||||||
http,
|
http,
|
||||||
cache,
|
cache,
|
||||||
consent_cookie: format!(
|
consent_cookie: format!(
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use std::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{anyhow, bail, Result};
|
use anyhow::{anyhow, bail, Result};
|
||||||
use chrono::{DateTime, NaiveDateTime, NaiveTime, Utc};
|
use chrono::{NaiveDateTime, NaiveTime, TimeZone, Utc};
|
||||||
use fancy_regex::Regex;
|
use fancy_regex::Regex;
|
||||||
use log::{error, warn};
|
use log::{error, warn};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
|
@ -16,8 +16,6 @@ use url::Url;
|
||||||
use super::{response, ClientType, ContextYT, RustyTube, YTClient};
|
use super::{response, ClientType, ContextYT, RustyTube, YTClient};
|
||||||
use crate::{client::response::player, deobfuscate::Deobfuscator, model::*, util};
|
use crate::{client::response::player, deobfuscate::Deobfuscator, model::*, util};
|
||||||
|
|
||||||
// REQUEST
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct QPlayer {
|
struct QPlayer {
|
||||||
|
|
@ -369,7 +367,7 @@ fn map_player_data(response: response::Player, deobf: &Deobfuscator) -> Result<V
|
||||||
},
|
},
|
||||||
publish_date: microformat.as_ref().map(|m| {
|
publish_date: microformat.as_ref().map(|m| {
|
||||||
let ndt = NaiveDateTime::new(m.publish_date, NaiveTime::from_hms(0, 0, 0));
|
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,
|
view_count: video_details.view_count,
|
||||||
keywords: video_details
|
keywords: video_details
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
// REQUEST
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
@ -18,10 +16,11 @@ struct QPlaylist {
|
||||||
browse_id: String,
|
browse_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct TmpEntry {
|
#[serde(rename_all = "camelCase")]
|
||||||
pub title: String,
|
struct QPlaylistCont {
|
||||||
pub video_id: String,
|
context: ContextYT,
|
||||||
|
continuation: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RustyTube {
|
impl RustyTube {
|
||||||
|
|
@ -46,6 +45,51 @@ impl RustyTube {
|
||||||
|
|
||||||
map_playlist(&playlist_response)
|
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> {
|
fn map_playlist(response: &response::Playlist) -> Result<Playlist> {
|
||||||
|
|
@ -74,49 +118,7 @@ fn map_playlist(response: &response::Playlist) -> Result<Playlist> {
|
||||||
.playlist_video_list_renderer
|
.playlist_video_list_renderer
|
||||||
.contents;
|
.contents;
|
||||||
|
|
||||||
let mut ctoken: Option<String> = None;
|
let (videos, ctoken) = map_playlist_items(video_items);
|
||||||
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 thumbnail_renderer = some_or_bail!(
|
let thumbnail_renderer = some_or_bail!(
|
||||||
response
|
response
|
||||||
|
|
@ -151,7 +153,7 @@ fn map_playlist(response: &response::Playlist) -> Result<Playlist> {
|
||||||
match &response.header.playlist_header_renderer.num_videos_text {
|
match &response.header.playlist_header_renderer.num_videos_text {
|
||||||
Text::Multiple { runs } =>
|
Text::Multiple { runs } =>
|
||||||
if runs.len() == 2 && runs[1] == " videos" {
|
if runs.len() == 2 && runs[1] == " videos" {
|
||||||
runs[0].parse().ok()
|
runs[0].replace(",", "").replace(".", "").parse().ok()
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
|
|
@ -175,6 +177,11 @@ fn map_playlist(response: &response::Playlist) -> Result<Playlist> {
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let id = response
|
||||||
|
.header
|
||||||
|
.playlist_header_renderer
|
||||||
|
.playlist_id
|
||||||
|
.to_owned();
|
||||||
let name = response.header.playlist_header_renderer.title.to_owned();
|
let name = response.header.playlist_header_renderer.title.to_owned();
|
||||||
let description = response
|
let description = response
|
||||||
.header
|
.header
|
||||||
|
|
@ -201,16 +208,65 @@ fn map_playlist(response: &response::Playlist) -> Result<Playlist> {
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Playlist {
|
Ok(Playlist {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
videos,
|
videos,
|
||||||
n_videos,
|
n_videos,
|
||||||
ctoken,
|
ctoken,
|
||||||
name,
|
|
||||||
thumbnails,
|
thumbnails,
|
||||||
description,
|
description,
|
||||||
channel,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{fs::File, io::BufReader, path::Path};
|
use std::{fs::File, io::BufReader, path::Path};
|
||||||
|
|
@ -299,6 +355,7 @@ mod tests {
|
||||||
let rt = RustyTube::new();
|
let rt = RustyTube::new();
|
||||||
let playlist = rt.get_playlist(id).await.unwrap();
|
let playlist = rt.get_playlist(id).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(playlist.id, id);
|
||||||
assert_eq!(playlist.name, name);
|
assert_eq!(playlist.name, name);
|
||||||
assert!(!playlist.videos.is_empty());
|
assert!(!playlist.videos.is_empty());
|
||||||
assert_eq!(playlist.ctoken.is_some(), is_long);
|
assert_eq!(playlist.ctoken.is_some(), is_long);
|
||||||
|
|
@ -323,4 +380,19 @@ mod tests {
|
||||||
let playlist_data = map_playlist(&playlist).unwrap();
|
let playlist_data = map_playlist(&playlist).unwrap();
|
||||||
insta::assert_yaml_snapshot!(format!("map_playlist_data_{}", name), playlist_data);
|
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 player;
|
||||||
pub mod playlist;
|
pub mod playlist;
|
||||||
pub mod playlist_music;
|
pub mod playlist_music;
|
||||||
|
|
||||||
|
pub use channel::Channel;
|
||||||
pub use player::Player;
|
pub use player::Player;
|
||||||
pub use playlist::Playlist;
|
pub use playlist::Playlist;
|
||||||
pub use playlist_music::PlaylistMusic;
|
pub use playlist_music::PlaylistMusic;
|
||||||
|
|
@ -44,10 +46,19 @@ pub struct Thumbnail {
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ContinuationItemRenderer {
|
pub enum VideoListItem<T> {
|
||||||
pub continuation_endpoint: ContinuationEndpoint,
|
#[serde(alias = "playlistVideoRenderer")]
|
||||||
|
GridVideoRenderer {
|
||||||
|
#[serde(flatten)]
|
||||||
|
video: T,
|
||||||
|
},
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
ContinuationItemRenderer {
|
||||||
|
continuation_endpoint: ContinuationEndpoint,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -24,27 +24,15 @@ pub enum PlayabilityStatus {
|
||||||
Ok { live_streamability: Option<Empty> },
|
Ok { live_streamability: Option<Empty> },
|
||||||
/// Video cant be played because of DRM / Geoblock
|
/// Video cant be played because of DRM / Geoblock
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
Unplayable {
|
Unplayable { reason: String },
|
||||||
reason: String,
|
|
||||||
// error_screen: Option<ErrorScreen>,
|
|
||||||
},
|
|
||||||
/// Age limit / Private video
|
/// Age limit / Private video
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
LoginRequired {
|
LoginRequired { reason: String },
|
||||||
reason: String,
|
|
||||||
// error_screen: Option<ErrorScreen>
|
|
||||||
},
|
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
LiveStreamOffline {
|
LiveStreamOffline { reason: String },
|
||||||
reason: String,
|
|
||||||
// error_screen: Option<ErrorScreen>
|
|
||||||
},
|
|
||||||
/// Video was censored / deleted
|
/// Video was censored / deleted
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
Error {
|
Error { reason: String },
|
||||||
reason: String,
|
|
||||||
// error_screen: Option<ErrorScreen>
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use serde_with::{json::JsonString, DefaultOnError, VecSkipError};
|
||||||
|
|
||||||
use crate::serializer::text::{Text, TextLink};
|
use crate::serializer::text::{Text, TextLink};
|
||||||
|
|
||||||
use super::{ContentRenderer, ContentsRenderer, ContinuationEndpoint, Thumbnails, ThumbnailsWrap};
|
use super::{ContentRenderer, ContentsRenderer, Thumbnails, ThumbnailsWrap, VideoListItem};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|
@ -14,6 +14,14 @@ pub struct Playlist {
|
||||||
pub sidebar: Sidebar,
|
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)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Contents {
|
pub struct Contents {
|
||||||
|
|
@ -49,21 +57,7 @@ pub struct PlaylistVideoListRenderer {
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PlaylistVideoList {
|
pub struct PlaylistVideoList {
|
||||||
#[serde_as(as = "VecSkipError<_>")]
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
pub contents: Vec<PlaylistVideoItem>,
|
pub contents: Vec<VideoListItem<PlaylistVideo>>,
|
||||||
}
|
|
||||||
|
|
||||||
#[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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
|
|
@ -152,3 +146,18 @@ pub struct VideoOwner {
|
||||||
#[serde_as(as = "crate::serializer::text::TextLink")]
|
#[serde_as(as = "crate::serializer::text::TextLink")]
|
||||||
pub title: 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
|
source: src/client/playlist.rs
|
||||||
expression: playlist_data
|
expression: playlist_data
|
||||||
---
|
---
|
||||||
|
id: PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ
|
||||||
|
name: Die schönsten deutschen Lieder | Beliebteste Lieder | Beste Deutsche Musik 2022
|
||||||
videos:
|
videos:
|
||||||
- id: Bkj3IVIO2Os
|
- id: Bkj3IVIO2Os
|
||||||
title: Stereoact feat. Kerstin Ott - Die Immer Lacht (Official Video HD)
|
title: Stereoact feat. Kerstin Ott - Die Immer Lacht (Official Video HD)
|
||||||
|
|
@ -1905,7 +1907,6 @@ videos:
|
||||||
name: KMNGANG
|
name: KMNGANG
|
||||||
n_videos: 495
|
n_videos: 495
|
||||||
ctoken: 4qmFsgJhEiRWTFBMNWREeDY4MVQ0YlI3WkYxSXVXek92MW9tbFJiRTdQaUoaFENBRjZCbEJVT2tOSFdRJTNEJTNEmgIiUEw1ZER4NjgxVDRiUjdaRjFJdVd6T3Yxb21sUmJFN1BpSg%3D%3D
|
ctoken: 4qmFsgJhEiRWTFBMNWREeDY4MVQ0YlI3WkYxSXVXek92MW9tbFJiRTdQaUoaFENBRjZCbEJVT2tOSFdRJTNEJTNEmgIiUEw1ZER4NjgxVDRiUjdaRjFJdVd6T3Yxb21sUmJFN1BpSg%3D%3D
|
||||||
name: Die schönsten deutschen Lieder | Beliebteste Lieder | Beste Deutsche Musik 2022
|
|
||||||
thumbnails:
|
thumbnails:
|
||||||
- url: "https://i.ytimg.com/vi/Bkj3IVIO2Os/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLBShlXfy9oWvTy2ntoHxmuhBlZP3g"
|
- url: "https://i.ytimg.com/vi/Bkj3IVIO2Os/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLBShlXfy9oWvTy2ntoHxmuhBlZP3g"
|
||||||
width: 168
|
width: 168
|
||||||
|
|
@ -1923,4 +1924,5 @@ description: ~
|
||||||
channel:
|
channel:
|
||||||
id: UCIekuFeMaV78xYfvpmoCnPg
|
id: UCIekuFeMaV78xYfvpmoCnPg
|
||||||
name: Best Music
|
name: Best Music
|
||||||
|
last_update: ~
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
source: src/client/playlist.rs
|
source: src/client/playlist.rs
|
||||||
expression: playlist_data
|
expression: playlist_data
|
||||||
---
|
---
|
||||||
|
id: PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe
|
||||||
|
name: Minecraft SHINE
|
||||||
videos:
|
videos:
|
||||||
- id: X82TrticM4A
|
- id: X82TrticM4A
|
||||||
title: Minecraft SHINE (Trailer)
|
title: Minecraft SHINE (Trailer)
|
||||||
|
|
@ -1259,7 +1261,6 @@ videos:
|
||||||
name: Chaosflo44
|
name: Chaosflo44
|
||||||
n_videos: 66
|
n_videos: 66
|
||||||
ctoken: ~
|
ctoken: ~
|
||||||
name: Minecraft SHINE
|
|
||||||
thumbnails:
|
thumbnails:
|
||||||
- url: "https://i.ytimg.com/vi/X82TrticM4A/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLBeSNDZEaBoP0KX9_Ayn3VO3X8rkw"
|
- url: "https://i.ytimg.com/vi/X82TrticM4A/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLBeSNDZEaBoP0KX9_Ayn3VO3X8rkw"
|
||||||
width: 168
|
width: 168
|
||||||
|
|
@ -1277,4 +1278,5 @@ description: "SHINE - Survival Hardcore in New Environment: Auf einem Server mac
|
||||||
channel:
|
channel:
|
||||||
id: UCQM0bS4_04-Y4JuYrgmnpZQ
|
id: UCQM0bS4_04-Y4JuYrgmnpZQ
|
||||||
name: Chaosflo44
|
name: Chaosflo44
|
||||||
|
last_update: ~
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
source: src/client/playlist.rs
|
source: src/client/playlist.rs
|
||||||
expression: playlist_data
|
expression: playlist_data
|
||||||
---
|
---
|
||||||
|
id: RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk
|
||||||
|
name: Easy Pop
|
||||||
videos:
|
videos:
|
||||||
- id: psuRGfAaju4
|
- id: psuRGfAaju4
|
||||||
title: Owl City - Fireflies (Official Music Video)
|
title: Owl City - Fireflies (Official Music Video)
|
||||||
|
|
@ -1848,7 +1850,6 @@ videos:
|
||||||
name: Diaven - Topic
|
name: Diaven - Topic
|
||||||
n_videos: 97
|
n_videos: 97
|
||||||
ctoken: ~
|
ctoken: ~
|
||||||
name: Easy Pop
|
|
||||||
thumbnails:
|
thumbnails:
|
||||||
- url: "https://i9.ytimg.com/s_p/RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk/mqdefault.jpg?sqp=CPDmr5gGir7X7AMGCLaHmpgG&rs=AOn4CLDg9xLeJDPeMtbWfp19VFd6vCQzqQ&v=1661371318"
|
- url: "https://i9.ytimg.com/s_p/RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk/mqdefault.jpg?sqp=CPDmr5gGir7X7AMGCLaHmpgG&rs=AOn4CLDg9xLeJDPeMtbWfp19VFd6vCQzqQ&v=1661371318"
|
||||||
width: 180
|
width: 180
|
||||||
|
|
@ -1861,4 +1862,5 @@ thumbnails:
|
||||||
height: 1200
|
height: 1200
|
||||||
description: ~
|
description: ~
|
||||||
channel: ~
|
channel: ~
|
||||||
|
last_update: ~
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,9 +103,10 @@ fn get_sig_fn(player_js: &str) -> Result<String> {
|
||||||
.as_str()
|
.as_str()
|
||||||
+ ";";
|
+ ";";
|
||||||
|
|
||||||
let helper_object_name_pattern = Regex::new(";([A-Za-z0-9_\\$]{2})\\...\\(").unwrap();
|
static HELPER_OBJECT_NAME_PATTERN: Lazy<Regex> =
|
||||||
|
Lazy::new(|| Regex::new(";([A-Za-z0-9_\\$]{2})\\...\\(").unwrap());
|
||||||
let helper_object_name = some_or_bail!(
|
let helper_object_name = some_or_bail!(
|
||||||
helper_object_name_pattern
|
HELPER_OBJECT_NAME_PATTERN
|
||||||
.captures(&deobfuscate_function)
|
.captures(&deobfuscate_function)
|
||||||
.ok()
|
.ok()
|
||||||
.flatten(),
|
.flatten(),
|
||||||
|
|
@ -145,12 +146,13 @@ fn deobfuscate_sig(sig: &str, sig_fn: &str) -> Result<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_nsig_fn_name(player_js: &str) -> Result<String> {
|
fn get_nsig_fn_name(player_js: &str) -> Result<String> {
|
||||||
let function_name_pattern =
|
static FUNCTION_NAME_PATTERN: Lazy<Regex> = Lazy::new(|| {
|
||||||
Regex::new("\\.get\\(\"n\"\\)\\)&&\\(b=([a-zA-Z0-9$]+)(?:\\[(\\d+)])?\\([a-zA-Z0-9]\\)")
|
Regex::new("\\.get\\(\"n\"\\)\\)&&\\(b=([a-zA-Z0-9$]+)(?:\\[(\\d+)])?\\([a-zA-Z0-9]\\)")
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
let fname_match = some_or_bail!(
|
let fname_match = some_or_bail!(
|
||||||
function_name_pattern.captures(player_js).ok().flatten(),
|
FUNCTION_NAME_PATTERN.captures(player_js).ok().flatten(),
|
||||||
Err(anyhow!("could not find n_deobf function"))
|
Err(anyhow!("could not find n_deobf function"))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -315,11 +317,12 @@ async fn get_player_js_url(http: &Client) -> Result<String> {
|
||||||
.error_for_status()?;
|
.error_for_status()?;
|
||||||
let text = resp.text().await?;
|
let text = resp.text().await?;
|
||||||
|
|
||||||
let player_hash_pattern =
|
static PLAYER_HASH_PATTERN: Lazy<Regex> = Lazy::new(|| {
|
||||||
Regex::new(r#"https:\\\/\\\/www\.youtube\.com\\\/s\\\/player\\\/([a-z0-9]{8})\\\/"#)
|
Regex::new(r#"https:\\\/\\\/www\.youtube\.com\\\/s\\\/player\\\/([a-z0-9]{8})\\\/"#)
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
});
|
||||||
let player_hash = some_or_bail!(
|
let player_hash = some_or_bail!(
|
||||||
player_hash_pattern.captures(&text)?,
|
PLAYER_HASH_PATTERN.captures(&text)?,
|
||||||
Err(anyhow!("could not find player hash"))
|
Err(anyhow!("could not find player hash"))
|
||||||
)
|
)
|
||||||
.get(1)
|
.get(1)
|
||||||
|
|
@ -338,10 +341,11 @@ async fn get_response(http: &Client, url: &str) -> Result<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_sts(player_js: &str) -> Result<String> {
|
fn get_sts(player_js: &str) -> Result<String> {
|
||||||
let sts_pattern = Regex::new("signatureTimestamp[=:](\\d+)").unwrap();
|
static STS_PATTERN: Lazy<Regex> =
|
||||||
|
Lazy::new(|| Regex::new("signatureTimestamp[=:](\\d+)").unwrap());
|
||||||
|
|
||||||
Ok(some_or_bail!(
|
Ok(some_or_bail!(
|
||||||
sts_pattern.captures(&player_js)?,
|
STS_PATTERN.captures(&player_js)?,
|
||||||
Err(anyhow!("could not find sts"))
|
Err(anyhow!("could not find sts"))
|
||||||
)
|
)
|
||||||
.get(1)
|
.get(1)
|
||||||
|
|
@ -357,7 +361,7 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use test_log::test;
|
use test_log::test;
|
||||||
|
|
||||||
const TEST_JS: Lazy<String> = Lazy::new(|| {
|
static TEST_JS: Lazy<String> = Lazy::new(|| {
|
||||||
let js_path = Path::new("testfiles/deobf/dummy_player.js");
|
let js_path = Path::new("testfiles/deobf/dummy_player.js");
|
||||||
std::fs::read_to_string(js_path).unwrap()
|
std::fs::read_to_string(js_path).unwrap()
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ use tokio::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{stream_filter::Filter, AudioCodec, FileFormat, VideoPlayer, VideoCodec},
|
model::{stream_filter::Filter, AudioCodec, FileFormat, VideoCodec, VideoPlayer},
|
||||||
util,
|
util,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ mod macros;
|
||||||
mod cache;
|
mod cache;
|
||||||
mod deobfuscate;
|
mod deobfuscate;
|
||||||
mod serializer;
|
mod serializer;
|
||||||
|
mod timeago;
|
||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
|
|
||||||
441
src/model/locale.rs
Normal file
441
src/model/locale.rs
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
use std::{fmt::Display, str::FromStr};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// GENERATED SECTION START //
|
||||||
|
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum Language {
|
||||||
|
/// Afrikaans
|
||||||
|
Af,
|
||||||
|
/// አማርኛ
|
||||||
|
Am,
|
||||||
|
/// العربية
|
||||||
|
Ar,
|
||||||
|
/// অসমীয়া
|
||||||
|
As,
|
||||||
|
/// Azərbaycan
|
||||||
|
Az,
|
||||||
|
/// Беларуская
|
||||||
|
Be,
|
||||||
|
/// Български
|
||||||
|
Bg,
|
||||||
|
/// বাংলা
|
||||||
|
Bn,
|
||||||
|
/// Bosanski
|
||||||
|
Bs,
|
||||||
|
/// Català
|
||||||
|
Ca,
|
||||||
|
/// Čeština
|
||||||
|
Cs,
|
||||||
|
/// Dansk
|
||||||
|
Da,
|
||||||
|
/// Deutsch
|
||||||
|
De,
|
||||||
|
/// Ελληνικά
|
||||||
|
El,
|
||||||
|
/// English (US)
|
||||||
|
En,
|
||||||
|
/// English (UK)
|
||||||
|
#[serde(rename = "en-GB")]
|
||||||
|
EnGb,
|
||||||
|
/// English (India)
|
||||||
|
#[serde(rename = "en-IN")]
|
||||||
|
EnIn,
|
||||||
|
/// Español (España)
|
||||||
|
Es,
|
||||||
|
/// Español (Latinoamérica)
|
||||||
|
#[serde(rename = "es-419")]
|
||||||
|
Es419,
|
||||||
|
/// Español (US)
|
||||||
|
#[serde(rename = "es-US")]
|
||||||
|
EsUs,
|
||||||
|
/// Eesti
|
||||||
|
Et,
|
||||||
|
/// Euskara
|
||||||
|
Eu,
|
||||||
|
/// فارسی
|
||||||
|
Fa,
|
||||||
|
/// Suomi
|
||||||
|
Fi,
|
||||||
|
/// Filipino
|
||||||
|
Fil,
|
||||||
|
/// Français
|
||||||
|
Fr,
|
||||||
|
/// Français (Canada)
|
||||||
|
#[serde(rename = "fr-CA")]
|
||||||
|
FrCa,
|
||||||
|
/// Galego
|
||||||
|
Gl,
|
||||||
|
/// ગુજરાતી
|
||||||
|
Gu,
|
||||||
|
/// हिन्दी
|
||||||
|
Hi,
|
||||||
|
/// Hrvatski
|
||||||
|
Hr,
|
||||||
|
/// Magyar
|
||||||
|
Hu,
|
||||||
|
/// Հայերեն
|
||||||
|
Hy,
|
||||||
|
/// Bahasa Indonesia
|
||||||
|
Id,
|
||||||
|
/// Íslenska
|
||||||
|
Is,
|
||||||
|
/// Italiano
|
||||||
|
It,
|
||||||
|
/// עברית
|
||||||
|
Iw,
|
||||||
|
/// 日本語
|
||||||
|
Ja,
|
||||||
|
/// ქართული
|
||||||
|
Ka,
|
||||||
|
/// Қазақ Тілі
|
||||||
|
Kk,
|
||||||
|
/// ខ្មែរ
|
||||||
|
Km,
|
||||||
|
/// ಕನ್ನಡ
|
||||||
|
Kn,
|
||||||
|
/// 한국어
|
||||||
|
Ko,
|
||||||
|
/// Кыргызча
|
||||||
|
Ky,
|
||||||
|
/// ລາວ
|
||||||
|
Lo,
|
||||||
|
/// Lietuvių
|
||||||
|
Lt,
|
||||||
|
/// Latviešu valoda
|
||||||
|
Lv,
|
||||||
|
/// Македонски
|
||||||
|
Mk,
|
||||||
|
/// മലയാളം
|
||||||
|
Ml,
|
||||||
|
/// Монгол
|
||||||
|
Mn,
|
||||||
|
/// मराठी
|
||||||
|
Mr,
|
||||||
|
/// Bahasa Malaysia
|
||||||
|
Ms,
|
||||||
|
/// ဗမာ
|
||||||
|
My,
|
||||||
|
/// नेपाली
|
||||||
|
Ne,
|
||||||
|
/// Nederlands
|
||||||
|
Nl,
|
||||||
|
/// Norsk
|
||||||
|
No,
|
||||||
|
/// ଓଡ଼ିଆ
|
||||||
|
Or,
|
||||||
|
/// ਪੰਜਾਬੀ
|
||||||
|
Pa,
|
||||||
|
/// Polski
|
||||||
|
Pl,
|
||||||
|
/// Português (Brasil)
|
||||||
|
Pt,
|
||||||
|
/// Português
|
||||||
|
#[serde(rename = "pt-PT")]
|
||||||
|
PtPt,
|
||||||
|
/// Română
|
||||||
|
Ro,
|
||||||
|
/// Русский
|
||||||
|
Ru,
|
||||||
|
/// සිංහල
|
||||||
|
Si,
|
||||||
|
/// Slovenčina
|
||||||
|
Sk,
|
||||||
|
/// Slovenščina
|
||||||
|
Sl,
|
||||||
|
/// Shqip
|
||||||
|
Sq,
|
||||||
|
/// Српски
|
||||||
|
Sr,
|
||||||
|
/// Srpski
|
||||||
|
#[serde(rename = "sr-Latn")]
|
||||||
|
SrLatn,
|
||||||
|
/// Svenska
|
||||||
|
Sv,
|
||||||
|
/// Kiswahili
|
||||||
|
Sw,
|
||||||
|
/// தமிழ்
|
||||||
|
Ta,
|
||||||
|
/// తెలుగు
|
||||||
|
Te,
|
||||||
|
/// ภาษาไทย
|
||||||
|
Th,
|
||||||
|
/// Türkçe
|
||||||
|
Tr,
|
||||||
|
/// Українська
|
||||||
|
Uk,
|
||||||
|
/// اردو
|
||||||
|
Ur,
|
||||||
|
/// O‘zbek
|
||||||
|
Uz,
|
||||||
|
/// Tiếng Việt
|
||||||
|
Vi,
|
||||||
|
/// 中文 (简体)
|
||||||
|
#[serde(rename = "zh-CN")]
|
||||||
|
ZhCn,
|
||||||
|
/// 中文 (香港)
|
||||||
|
#[serde(rename = "zh-HK")]
|
||||||
|
ZhHk,
|
||||||
|
/// 中文 (繁體)
|
||||||
|
#[serde(rename = "zh-TW")]
|
||||||
|
ZhTw,
|
||||||
|
/// IsiZulu
|
||||||
|
Zu,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum Country {
|
||||||
|
/// United Arab Emirates
|
||||||
|
Ae,
|
||||||
|
/// Argentina
|
||||||
|
Ar,
|
||||||
|
/// Austria
|
||||||
|
At,
|
||||||
|
/// Australia
|
||||||
|
Au,
|
||||||
|
/// Azerbaijan
|
||||||
|
Az,
|
||||||
|
/// Bosnia and Herzegovina
|
||||||
|
Ba,
|
||||||
|
/// Bangladesh
|
||||||
|
Bd,
|
||||||
|
/// Belgium
|
||||||
|
Be,
|
||||||
|
/// Bulgaria
|
||||||
|
Bg,
|
||||||
|
/// Bahrain
|
||||||
|
Bh,
|
||||||
|
/// Bolivia
|
||||||
|
Bo,
|
||||||
|
/// Brazil
|
||||||
|
Br,
|
||||||
|
/// Belarus
|
||||||
|
By,
|
||||||
|
/// Canada
|
||||||
|
Ca,
|
||||||
|
/// Switzerland
|
||||||
|
Ch,
|
||||||
|
/// Chile
|
||||||
|
Cl,
|
||||||
|
/// Colombia
|
||||||
|
Co,
|
||||||
|
/// Costa Rica
|
||||||
|
Cr,
|
||||||
|
/// Cyprus
|
||||||
|
Cy,
|
||||||
|
/// Czechia
|
||||||
|
Cz,
|
||||||
|
/// Germany
|
||||||
|
De,
|
||||||
|
/// Denmark
|
||||||
|
Dk,
|
||||||
|
/// Dominican Republic
|
||||||
|
Do,
|
||||||
|
/// Algeria
|
||||||
|
Dz,
|
||||||
|
/// Ecuador
|
||||||
|
Ec,
|
||||||
|
/// Estonia
|
||||||
|
Ee,
|
||||||
|
/// Egypt
|
||||||
|
Eg,
|
||||||
|
/// Spain
|
||||||
|
Es,
|
||||||
|
/// Finland
|
||||||
|
Fi,
|
||||||
|
/// France
|
||||||
|
Fr,
|
||||||
|
/// United Kingdom
|
||||||
|
Gb,
|
||||||
|
/// Georgia
|
||||||
|
Ge,
|
||||||
|
/// Ghana
|
||||||
|
Gh,
|
||||||
|
/// Greece
|
||||||
|
Gr,
|
||||||
|
/// Guatemala
|
||||||
|
Gt,
|
||||||
|
/// Hong Kong
|
||||||
|
Hk,
|
||||||
|
/// Honduras
|
||||||
|
Hn,
|
||||||
|
/// Croatia
|
||||||
|
Hr,
|
||||||
|
/// Hungary
|
||||||
|
Hu,
|
||||||
|
/// Indonesia
|
||||||
|
Id,
|
||||||
|
/// Ireland
|
||||||
|
Ie,
|
||||||
|
/// Israel
|
||||||
|
Il,
|
||||||
|
/// India
|
||||||
|
In,
|
||||||
|
/// Iraq
|
||||||
|
Iq,
|
||||||
|
/// Iceland
|
||||||
|
Is,
|
||||||
|
/// Italy
|
||||||
|
It,
|
||||||
|
/// Jamaica
|
||||||
|
Jm,
|
||||||
|
/// Jordan
|
||||||
|
Jo,
|
||||||
|
/// Japan
|
||||||
|
Jp,
|
||||||
|
/// Kenya
|
||||||
|
Ke,
|
||||||
|
/// Cambodia
|
||||||
|
Kh,
|
||||||
|
/// South Korea
|
||||||
|
Kr,
|
||||||
|
/// Kuwait
|
||||||
|
Kw,
|
||||||
|
/// Kazakhstan
|
||||||
|
Kz,
|
||||||
|
/// Laos
|
||||||
|
La,
|
||||||
|
/// Lebanon
|
||||||
|
Lb,
|
||||||
|
/// Liechtenstein
|
||||||
|
Li,
|
||||||
|
/// Sri Lanka
|
||||||
|
Lk,
|
||||||
|
/// Lithuania
|
||||||
|
Lt,
|
||||||
|
/// Luxembourg
|
||||||
|
Lu,
|
||||||
|
/// Latvia
|
||||||
|
Lv,
|
||||||
|
/// Libya
|
||||||
|
Ly,
|
||||||
|
/// Morocco
|
||||||
|
Ma,
|
||||||
|
/// Montenegro
|
||||||
|
Me,
|
||||||
|
/// North Macedonia
|
||||||
|
Mk,
|
||||||
|
/// Malta
|
||||||
|
Mt,
|
||||||
|
/// Mexico
|
||||||
|
Mx,
|
||||||
|
/// Malaysia
|
||||||
|
My,
|
||||||
|
/// Nigeria
|
||||||
|
Ng,
|
||||||
|
/// Nicaragua
|
||||||
|
Ni,
|
||||||
|
/// Netherlands
|
||||||
|
Nl,
|
||||||
|
/// Norway
|
||||||
|
No,
|
||||||
|
/// Nepal
|
||||||
|
Np,
|
||||||
|
/// New Zealand
|
||||||
|
Nz,
|
||||||
|
/// Oman
|
||||||
|
Om,
|
||||||
|
/// Panama
|
||||||
|
Pa,
|
||||||
|
/// Peru
|
||||||
|
Pe,
|
||||||
|
/// Papua New Guinea
|
||||||
|
Pg,
|
||||||
|
/// Philippines
|
||||||
|
Ph,
|
||||||
|
/// Pakistan
|
||||||
|
Pk,
|
||||||
|
/// Poland
|
||||||
|
Pl,
|
||||||
|
/// Puerto Rico
|
||||||
|
Pr,
|
||||||
|
/// Portugal
|
||||||
|
Pt,
|
||||||
|
/// Paraguay
|
||||||
|
Py,
|
||||||
|
/// Qatar
|
||||||
|
Qa,
|
||||||
|
/// Romania
|
||||||
|
Ro,
|
||||||
|
/// Serbia
|
||||||
|
Rs,
|
||||||
|
/// Russia
|
||||||
|
Ru,
|
||||||
|
/// Saudi Arabia
|
||||||
|
Sa,
|
||||||
|
/// Sweden
|
||||||
|
Se,
|
||||||
|
/// Singapore
|
||||||
|
Sg,
|
||||||
|
/// Slovenia
|
||||||
|
Si,
|
||||||
|
/// Slovakia
|
||||||
|
Sk,
|
||||||
|
/// Senegal
|
||||||
|
Sn,
|
||||||
|
/// El Salvador
|
||||||
|
Sv,
|
||||||
|
/// Thailand
|
||||||
|
Th,
|
||||||
|
/// Tunisia
|
||||||
|
Tn,
|
||||||
|
/// Turkey
|
||||||
|
Tr,
|
||||||
|
/// Taiwan
|
||||||
|
Tw,
|
||||||
|
/// Tanzania
|
||||||
|
Tz,
|
||||||
|
/// Ukraine
|
||||||
|
Ua,
|
||||||
|
/// Uganda
|
||||||
|
Ug,
|
||||||
|
/// United States
|
||||||
|
Us,
|
||||||
|
/// Uruguay
|
||||||
|
Uy,
|
||||||
|
/// Venezuela
|
||||||
|
Ve,
|
||||||
|
/// Vietnam
|
||||||
|
Vn,
|
||||||
|
/// Yemen
|
||||||
|
Ye,
|
||||||
|
/// South Africa
|
||||||
|
Za,
|
||||||
|
/// Zimbabwe
|
||||||
|
Zw,
|
||||||
|
}
|
||||||
|
// GENERATED SECTION END //
|
||||||
|
|
||||||
|
impl Display for Language {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(
|
||||||
|
&serde_json::to_string(self).map_or("".to_owned(), |s| s[1..s.len() - 1].to_owned()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Country {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(
|
||||||
|
&serde_json::to_string(self).map_or("".to_owned(), |s| s[1..s.len() - 1].to_owned()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Language {
|
||||||
|
type Err = serde_json::Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
serde_json::from_str(&format!("\"{}\"", s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for Country {
|
||||||
|
type Err = serde_json::Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
serde_json::from_str(&format!("\"{}\"", s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
|
mod locale;
|
||||||
mod ordering;
|
mod ordering;
|
||||||
pub mod stream_filter;
|
pub mod stream_filter;
|
||||||
|
|
||||||
|
pub use locale::{Country, Language};
|
||||||
|
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
@ -22,13 +25,15 @@ pub struct VideoPlayer {
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Playlist {
|
pub struct Playlist {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
pub videos: Vec<Video>,
|
pub videos: Vec<Video>,
|
||||||
pub n_videos: u32,
|
pub n_videos: u32,
|
||||||
pub ctoken: Option<String>,
|
pub ctoken: Option<String>,
|
||||||
pub name: String,
|
|
||||||
pub thumbnails: Vec<Thumbnail>,
|
pub thumbnails: Vec<Thumbnail>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub channel: Option<Channel>,
|
pub channel: Option<Channel>,
|
||||||
|
pub last_update: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
|
@ -176,12 +181,6 @@ pub struct Subtitle {
|
||||||
pub auto_generated: bool,
|
pub auto_generated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct Locale {
|
|
||||||
pub lang: String,
|
|
||||||
pub country: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct Video {
|
pub struct Video {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
|
||||||
|
|
@ -331,14 +331,14 @@ mod tests {
|
||||||
use rstest::rstest;
|
use rstest::rstest;
|
||||||
use velcro::hash_set;
|
use velcro::hash_set;
|
||||||
|
|
||||||
const PLAYER_ML: Lazy<VideoPlayer> = Lazy::new(|| {
|
static PLAYER_ML: Lazy<VideoPlayer> = Lazy::new(|| {
|
||||||
let json_path = Path::new("testfiles/player_model/multilanguage.json");
|
let json_path = Path::new("testfiles/player_model/multilanguage.json");
|
||||||
let json_file = File::open(json_path).unwrap();
|
let json_file = File::open(json_path).unwrap();
|
||||||
|
|
||||||
serde_json::from_reader(BufReader::new(json_file)).unwrap()
|
serde_json::from_reader(BufReader::new(json_file)).unwrap()
|
||||||
});
|
});
|
||||||
|
|
||||||
const PLAYER_HDR: Lazy<VideoPlayer> = Lazy::new(|| {
|
static PLAYER_HDR: Lazy<VideoPlayer> = Lazy::new(|| {
|
||||||
let json_path = Path::new("testfiles/player_model/hdr.json");
|
let json_path = Path::new("testfiles/player_model/hdr.json");
|
||||||
let json_file = File::open(json_path).unwrap();
|
let json_file = File::open(json_path).unwrap();
|
||||||
|
|
||||||
|
|
@ -356,8 +356,7 @@ mod tests {
|
||||||
#[case::noformat(Filter::default().audio_formats(hash_set!()).to_owned(), None)]
|
#[case::noformat(Filter::default().audio_formats(hash_set!()).to_owned(), None)]
|
||||||
#[case::nocodec(Filter::default().audio_codecs(hash_set!()).to_owned(), None)]
|
#[case::nocodec(Filter::default().audio_codecs(hash_set!()).to_owned(), None)]
|
||||||
fn t_select_audio_stream(#[case] filter: Filter, #[case] expect_url: Option<&str>) {
|
fn t_select_audio_stream(#[case] filter: Filter, #[case] expect_url: Option<&str>) {
|
||||||
let player_data = PLAYER_ML;
|
let selection = PLAYER_ML.select_audio_stream(&filter);
|
||||||
let selection = player_data.select_audio_stream(&filter);
|
|
||||||
|
|
||||||
match expect_url {
|
match expect_url {
|
||||||
Some(expect_url) => assert_eq!(selection.unwrap().url, expect_url),
|
Some(expect_url) => assert_eq!(selection.unwrap().url, expect_url),
|
||||||
|
|
@ -376,8 +375,7 @@ mod tests {
|
||||||
#[case::noformat(Filter::default().video_formats(hash_set!()).to_owned(), None)]
|
#[case::noformat(Filter::default().video_formats(hash_set!()).to_owned(), None)]
|
||||||
#[case::nocodec(Filter::default().video_codecs(hash_set!()).to_owned(), None)]
|
#[case::nocodec(Filter::default().video_codecs(hash_set!()).to_owned(), None)]
|
||||||
fn t_select_video_only_stream(#[case] filter: Filter, #[case] expect_url: Option<&str>) {
|
fn t_select_video_only_stream(#[case] filter: Filter, #[case] expect_url: Option<&str>) {
|
||||||
let player_data = PLAYER_HDR;
|
let selection = PLAYER_HDR.select_video_only_stream(&filter);
|
||||||
let selection = player_data.select_video_only_stream(&filter);
|
|
||||||
|
|
||||||
match expect_url {
|
match expect_url {
|
||||||
Some(expect_url) => assert_eq!(selection.unwrap().url, expect_url),
|
Some(expect_url) => assert_eq!(selection.unwrap().url, expect_url),
|
||||||
|
|
@ -412,8 +410,7 @@ mod tests {
|
||||||
#[case] expect_video_url: Option<&str>,
|
#[case] expect_video_url: Option<&str>,
|
||||||
#[case] expect_audio_url: Option<&str>,
|
#[case] expect_audio_url: Option<&str>,
|
||||||
) {
|
) {
|
||||||
let player_data = PLAYER_HDR;
|
let (video, audio) = PLAYER_HDR.select_video_audio_stream(&filter);
|
||||||
let (video, audio) = player_data.select_video_audio_stream(&filter);
|
|
||||||
|
|
||||||
match expect_video_url {
|
match expect_video_url {
|
||||||
Some(expect_url) => assert_eq!(video.unwrap().url, expect_url),
|
Some(expect_url) => assert_eq!(video.unwrap().url, expect_url),
|
||||||
|
|
|
||||||
1164
src/timeago.rs
Normal file
1164
src/timeago.rs
Normal file
File diff suppressed because it is too large
Load diff
43
src/util.rs
43
src/util.rs
|
|
@ -1,7 +1,8 @@
|
||||||
use std::collections::BTreeMap;
|
use std::{collections::BTreeMap, str::FromStr};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use fancy_regex::Regex;
|
use fancy_regex::Regex;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
|
@ -18,26 +19,28 @@ where
|
||||||
.map(|c| c.get(cg).unwrap().as_str().to_owned())
|
.map(|c| c.get(cg).unwrap().as_str().to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a random string with given length and byte charset.
|
/// Generate a random string with given length and byte charset.
|
||||||
fn random_string(charset: &[u8], length: usize) -> String {
|
fn random_string(charset: &[u8], length: usize) -> String {
|
||||||
let mut result = String::with_capacity(length);
|
let mut result = String::with_capacity(length);
|
||||||
let mut rng = rand::thread_rng();
|
let mut rng = rand::thread_rng();
|
||||||
|
|
||||||
unsafe {
|
for _ in 0..length {
|
||||||
for _ in 0..length {
|
result.push(char::from(charset[rng.gen_range(0..charset.len())]));
|
||||||
result.push(char::from(
|
|
||||||
*charset.get_unchecked(rng.gen_range(0..charset.len())),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate a 16 characters long random string used as a CPN (Content Playback Nonce)
|
||||||
pub fn generate_content_playback_nonce() -> String {
|
pub fn generate_content_playback_nonce() -> String {
|
||||||
random_string(CONTENT_PLAYBACK_NONCE_ALPHABET, 16)
|
random_string(CONTENT_PLAYBACK_NONCE_ALPHABET, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Split an URL into its base string and parameter map
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
///
|
||||||
|
/// `example.com/api?k1=v1&k2=v2 => example.com/api; {k1: v1, k2: v2}`
|
||||||
pub fn url_to_params(url: &str) -> Result<(String, BTreeMap<String, String>)> {
|
pub fn url_to_params(url: &str) -> Result<(String, BTreeMap<String, String>)> {
|
||||||
let parsed_url = Url::parse(url)?;
|
let parsed_url = Url::parse(url)?;
|
||||||
let url_params: BTreeMap<String, String> = parsed_url
|
let url_params: BTreeMap<String, String> = parsed_url
|
||||||
|
|
@ -50,3 +53,27 @@ pub fn url_to_params(url: &str) -> Result<(String, BTreeMap<String, String>)> {
|
||||||
|
|
||||||
Ok((url_base.to_string(), url_params))
|
Ok((url_base.to_string(), url_params))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a string after removing all non-numeric characters
|
||||||
|
pub fn parse_numeric<F>(string: &str) -> Result<F, F::Err>
|
||||||
|
where
|
||||||
|
F: FromStr,
|
||||||
|
{
|
||||||
|
static NUM_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new("\\D+").unwrap());
|
||||||
|
NUM_PATTERN.replace_all(string, "").parse()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use rstest::rstest;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[case("1.000", 1000)]
|
||||||
|
#[case("4 Hello World 2", 42)]
|
||||||
|
fn t_parse_num(#[case] string: &str, #[case] expect: u32) {
|
||||||
|
let n = parse_numeric::<u32>(string).unwrap();
|
||||||
|
assert_eq!(n, expect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
3239
testfiles/date/timeago.json
Normal file
3239
testfiles/date/timeago.json
Normal file
File diff suppressed because it is too large
Load diff
Reference in a new issue