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