feat: add timeago parser, playlist_cont

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -2,6 +2,9 @@ pub mod player;
pub mod playlist;
mod response;
#[cfg(test)]
mod scripts;
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
@ -15,7 +18,7 @@ use serde::{Deserialize, Serialize};
use crate::{
cache::{Cache, ClientData},
model::Locale,
model::{Country, Language},
util,
};
@ -68,10 +71,8 @@ struct ClientInfo {
platform: String,
#[serde(skip_serializing_if = "Option::is_none")]
original_url: Option<String>,
/// Language (`en`, `de`)
hl: String,
/// Country (`US`, `DE`)
gl: String,
hl: Language,
gl: Country,
}
#[derive(Clone, Debug, Serialize)]
@ -127,7 +128,7 @@ const ANDROID_API_KEY: &str = "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w";
const IOS_API_KEY: &str = "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc";
const IOS_DEVICE_MODEL: &str = "iPhone14,5";
const CLIENT_VERSION_REGEXES: Lazy<[Regex; 3]> = Lazy::new(|| {
static CLIENT_VERSION_REGEXES: Lazy<[Regex; 3]> = Lazy::new(|| {
[
Regex::new("INNERTUBE_CONTEXT_CLIENT_VERSION\":\"([0-9\\.]+?)\"").unwrap(),
Regex::new("innertube_context_client_version\":\"([0-9\\.]+?)\"").unwrap(),
@ -136,7 +137,7 @@ const CLIENT_VERSION_REGEXES: Lazy<[Regex; 3]> = Lazy::new(|| {
});
pub struct RustyTube {
pub locale: Arc<Locale>,
localization: Arc<Localization>,
cache: Cache,
desktop_client: Arc<DesktopClient>,
desktop_music_client: Arc<DesktopMusicClient>,
@ -145,17 +146,30 @@ pub struct RustyTube {
tvhtml5embed_client: Arc<TvHtml5EmbedClient>,
}
struct Localization {
language: Language,
content_country: Country,
}
impl RustyTube {
#[must_use]
pub fn new() -> Self {
Self::new_with_ua("en", "US", Some("rusty-tube.json".to_owned()))
Self::new_with_ua(
Language::En,
Country::Us,
Some("rusty-tube.json".to_owned()),
)
}
#[must_use]
pub fn new_with_ua(lang: &str, country: &str, cache_file: Option<String>) -> Self {
let locale = Arc::new(Locale {
lang: lang.to_owned(),
country: country.to_owned(),
pub fn new_with_ua(
language: Language,
content_country: Country,
cache_file: Option<String>,
) -> Self {
let loc = Arc::new(Localization {
language,
content_country,
});
let cache = match cache_file.as_ref() {
@ -164,13 +178,13 @@ impl RustyTube {
};
Self {
locale: locale.clone(),
localization: loc.clone(),
cache: cache.clone(),
desktop_client: Arc::new(DesktopClient::new(locale.clone(), cache.clone())),
desktop_music_client: Arc::new(DesktopMusicClient::new(locale.clone(), cache)),
android_client: Arc::new(AndroidClient::new(locale.clone())),
ios_client: Arc::new(IosClient::new(locale.clone())),
tvhtml5embed_client: Arc::new(TvHtml5EmbedClient::new(locale)),
desktop_client: Arc::new(DesktopClient::new(loc.clone(), cache.clone())),
desktop_music_client: Arc::new(DesktopMusicClient::new(loc.clone(), cache)),
android_client: Arc::new(AndroidClient::new(loc.clone())),
ios_client: Arc::new(IosClient::new(loc.clone())),
tvhtml5embed_client: Arc::new(TvHtml5EmbedClient::new(loc)),
}
}
@ -202,7 +216,7 @@ async fn exec_request_text(http: Client, request: Request) -> Result<String> {
}
pub struct DesktopClient {
locale: Arc<Locale>,
localization: Arc<Localization>,
http: Client,
cache: Cache,
consent_cookie: String,
@ -220,12 +234,12 @@ impl YTClient for DesktopClient {
platform: "DESKTOP".to_owned(),
original_url: Some("https://www.youtube.com/".to_owned()),
hl: match localized {
true => self.locale.lang.to_owned(),
false => "en".to_owned(),
true => self.localization.language,
false => Language::En,
},
gl: match localized {
true => self.locale.country.to_owned(),
false => "US".to_owned(),
true => self.localization.content_country,
false => Country::Us,
},
},
request: Some(RequestYT::default()),
@ -260,7 +274,7 @@ impl YTClient for DesktopClient {
}
impl DesktopClient {
fn new(locale: Arc<Locale>, cache: Cache) -> Self {
fn new(localization: Arc<Localization>, cache: Cache) -> Self {
let mut rng = rand::thread_rng();
let http = ClientBuilder::new()
@ -271,7 +285,7 @@ impl DesktopClient {
.expect("unable to build the HTTP client");
Self {
locale,
localization,
http,
cache,
consent_cookie: format!(
@ -329,7 +343,7 @@ impl DesktopClient {
}
pub struct AndroidClient {
locale: Arc<Locale>,
localization: Arc<Localization>,
http: Client,
}
@ -345,12 +359,12 @@ impl YTClient for AndroidClient {
platform: "MOBILE".to_owned(),
original_url: None,
hl: match localized {
true => self.locale.lang.to_owned(),
false => "en".to_owned(),
true => self.localization.language,
false => Language::En,
},
gl: match localized {
true => self.locale.country.to_owned(),
false => "US".to_owned(),
true => self.localization.content_country,
false => Country::Us,
},
},
request: None,
@ -384,22 +398,22 @@ impl YTClient for AndroidClient {
}
impl AndroidClient {
fn new(locale: Arc<Locale>) -> Self {
fn new(localization: Arc<Localization>) -> Self {
let http = ClientBuilder::new()
.user_agent(format!(
"com.google.android.youtube/{} (Linux; U; Android 12; {}) gzip",
MOBILE_CLIENT_VERSION, locale.country
MOBILE_CLIENT_VERSION, localization.content_country
))
.gzip(true)
.build()
.expect("unable to build the HTTP client");
Self { locale, http }
Self { localization, http }
}
}
pub struct IosClient {
locale: Arc<Locale>,
localization: Arc<Localization>,
http: Client,
}
@ -415,12 +429,12 @@ impl YTClient for IosClient {
platform: "MOBILE".to_owned(),
original_url: None,
hl: match localized {
true => self.locale.lang.to_owned(),
false => "en".to_owned(),
true => self.localization.language,
false => Language::En,
},
gl: match localized {
true => self.locale.country.to_owned(),
false => "US".to_owned(),
true => self.localization.content_country,
false => Country::Us,
},
},
request: None,
@ -451,22 +465,22 @@ impl YTClient for IosClient {
}
impl IosClient {
fn new(locale: Arc<Locale>) -> Self {
fn new(localization: Arc<Localization>) -> Self {
let http = ClientBuilder::new()
.user_agent(format!(
"com.google.ios.youtube/{} ({}; U; CPU iOS 15_4 like Mac OS X; {})",
MOBILE_CLIENT_VERSION, IOS_DEVICE_MODEL, locale.country
MOBILE_CLIENT_VERSION, IOS_DEVICE_MODEL, localization.content_country
))
.gzip(true)
.build()
.expect("unable to build the HTTP client");
Self { locale, http }
Self { localization, http }
}
}
pub struct TvHtml5EmbedClient {
locale: Arc<Locale>,
localization: Arc<Localization>,
http: Client,
}
@ -482,12 +496,12 @@ impl YTClient for TvHtml5EmbedClient {
platform: "TV".to_owned(),
original_url: None,
hl: match localized {
true => self.locale.lang.to_owned(),
false => "en".to_owned(),
true => self.localization.language,
false => Language::En,
},
gl: match localized {
true => self.locale.country.to_owned(),
false => "US".to_owned(),
true => self.localization.content_country,
false => Country::Us,
},
},
request: Some(RequestYT::default()),
@ -523,7 +537,7 @@ impl YTClient for TvHtml5EmbedClient {
}
impl TvHtml5EmbedClient {
fn new(locale: Arc<Locale>) -> Self {
fn new(localization: Arc<Localization>) -> Self {
let http = ClientBuilder::new()
.user_agent(DEFAULT_UA)
.gzip(true)
@ -531,12 +545,12 @@ impl TvHtml5EmbedClient {
.build()
.expect("unable to build the HTTP client");
Self { locale, http }
Self { localization, http }
}
}
pub struct DesktopMusicClient {
locale: Arc<Locale>,
localization: Arc<Localization>,
http: Client,
cache: Cache,
consent_cookie: String,
@ -554,12 +568,12 @@ impl YTClient for DesktopMusicClient {
platform: "DESKTOP".to_owned(),
original_url: Some("https://music.youtube.com/".to_owned()),
hl: match localized {
true => self.locale.lang.to_owned(),
false => "en".to_owned(),
true => self.localization.language,
false => Language::En,
},
gl: match localized {
true => self.locale.country.to_owned(),
false => "US".to_owned(),
true => self.localization.content_country,
false => Country::Us,
},
},
request: Some(RequestYT::default()),
@ -597,7 +611,7 @@ impl YTClient for DesktopMusicClient {
}
impl DesktopMusicClient {
fn new(locale: Arc<Locale>, cache: Cache) -> Self {
fn new(localization: Arc<Localization>, cache: Cache) -> Self {
let mut rng = rand::thread_rng();
let http = ClientBuilder::new()
@ -608,7 +622,7 @@ impl DesktopMusicClient {
.expect("unable to build the HTTP client");
Self {
locale,
localization,
http,
cache,
consent_cookie: format!(

View file

@ -5,7 +5,7 @@ use std::{
};
use anyhow::{anyhow, bail, Result};
use chrono::{DateTime, NaiveDateTime, NaiveTime, Utc};
use chrono::{NaiveDateTime, NaiveTime, TimeZone, Utc};
use fancy_regex::Regex;
use log::{error, warn};
use once_cell::sync::Lazy;
@ -16,8 +16,6 @@ use url::Url;
use super::{response, ClientType, ContextYT, RustyTube, YTClient};
use crate::{client::response::player, deobfuscate::Deobfuscator, model::*, util};
// REQUEST
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct QPlayer {
@ -369,7 +367,7 @@ fn map_player_data(response: response::Player, deobf: &Deobfuscator) -> Result<V
},
publish_date: microformat.as_ref().map(|m| {
let ndt = NaiveDateTime::new(m.publish_date, NaiveTime::from_hms(0, 0, 0));
DateTime::from_utc(ndt, Utc)
Utc.from_local_datetime(&ndt).unwrap()
}),
view_count: video_details.view_count,
keywords: video_details

View file

@ -1,5 +1,3 @@
// REQUEST
use anyhow::{anyhow, Result};
use reqwest::Method;
use serde::Serialize;
@ -18,10 +16,11 @@ struct QPlaylist {
browse_id: String,
}
#[derive(Clone, Debug)]
pub struct TmpEntry {
pub title: String,
pub video_id: String,
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct QPlaylistCont {
context: ContextYT,
continuation: String,
}
impl RustyTube {
@ -46,6 +45,51 @@ impl RustyTube {
map_playlist(&playlist_response)
}
pub async fn get_playlist_cont(&self, playlist: &mut Playlist) -> Result<()> {
match &playlist.ctoken {
Some(ctoken) => {
let client = self.get_ytclient(ClientType::Desktop);
let context = client.get_context(true).await;
let request_body = QPlaylistCont {
context,
continuation: ctoken.to_owned(),
};
let resp = client
.request_builder(Method::POST, "browse")
.await
.json(&request_body)
.send()
.await?
.error_for_status()?;
let cont_response = resp.json::<response::playlist::PlaylistCont>().await?;
let action = some_or_bail!(
cont_response
.on_response_received_actions
.iter()
.find(|a| a.append_continuation_items_action.target_id == playlist.id),
Err(anyhow!("no continuation action"))
);
let (mut videos, ctoken) =
map_playlist_items(&action.append_continuation_items_action.continuation_items);
playlist.videos.append(&mut videos);
playlist.ctoken = ctoken;
if playlist.ctoken.is_none() {
playlist.n_videos = playlist.videos.len() as u32;
}
Ok(())
}
None => Err(anyhow!("no ctoken")),
}
}
}
fn map_playlist(response: &response::Playlist) -> Result<Playlist> {
@ -74,49 +118,7 @@ fn map_playlist(response: &response::Playlist) -> Result<Playlist> {
.playlist_video_list_renderer
.contents;
let mut ctoken: Option<String> = None;
let videos = video_items
.iter()
.filter_map(|it| match it {
response::playlist::PlaylistVideoItem::PlaylistVideoRenderer { video } => {
match &video.channel {
TextLink::Browse {
text,
page_type,
browse_id,
} => match page_type {
PageType::Channel => Some(Video {
id: video.video_id.to_owned(),
title: video.title.to_owned(),
length: video.length_seconds,
thumbnails: video
.thumbnail
.thumbnails
.iter()
.map(|t| Thumbnail {
url: t.url.to_owned(),
width: t.width,
height: t.height,
})
.collect(),
channel: Channel {
id: browse_id.to_string(),
name: text.to_owned(),
},
}),
_ => None,
},
_ => None,
}
}
response::playlist::PlaylistVideoItem::ContinuationItemRenderer {
continuation_endpoint,
} => {
ctoken = Some(continuation_endpoint.continuation_command.token.to_owned());
None
}
})
.collect::<Vec<_>>();
let (videos, ctoken) = map_playlist_items(video_items);
let thumbnail_renderer = some_or_bail!(
response
@ -151,7 +153,7 @@ fn map_playlist(response: &response::Playlist) -> Result<Playlist> {
match &response.header.playlist_header_renderer.num_videos_text {
Text::Multiple { runs } =>
if runs.len() == 2 && runs[1] == " videos" {
runs[0].parse().ok()
runs[0].replace(",", "").replace(".", "").parse().ok()
} else {
None
},
@ -175,6 +177,11 @@ fn map_playlist(response: &response::Playlist) -> Result<Playlist> {
})
.collect::<Vec<_>>();
let id = response
.header
.playlist_header_renderer
.playlist_id
.to_owned();
let name = response.header.playlist_header_renderer.title.to_owned();
let description = response
.header
@ -201,16 +208,65 @@ fn map_playlist(response: &response::Playlist) -> Result<Playlist> {
};
Ok(Playlist {
id,
name,
videos,
n_videos,
ctoken,
name,
thumbnails,
description,
channel,
last_update: None,
})
}
fn map_playlist_items(
items: &Vec<response::VideoListItem<response::playlist::PlaylistVideo>>,
) -> (Vec<Video>, Option<String>) {
let mut ctoken: Option<String> = None;
let videos = items
.iter()
.filter_map(|it| match it {
response::VideoListItem::GridVideoRenderer { video } => match &video.channel {
TextLink::Browse {
text,
page_type,
browse_id,
} => match page_type {
PageType::Channel => Some(Video {
id: video.video_id.to_owned(),
title: video.title.to_owned(),
length: video.length_seconds,
thumbnails: video
.thumbnail
.thumbnails
.iter()
.map(|t| Thumbnail {
url: t.url.to_owned(),
width: t.width,
height: t.height,
})
.collect(),
channel: Channel {
id: browse_id.to_string(),
name: text.to_owned(),
},
}),
_ => None,
},
_ => None,
},
response::VideoListItem::ContinuationItemRenderer {
continuation_endpoint,
} => {
ctoken = Some(continuation_endpoint.continuation_command.token.to_owned());
None
}
})
.collect::<Vec<_>>();
(videos, ctoken)
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::BufReader, path::Path};
@ -299,6 +355,7 @@ mod tests {
let rt = RustyTube::new();
let playlist = rt.get_playlist(id).await.unwrap();
assert_eq!(playlist.id, id);
assert_eq!(playlist.name, name);
assert!(!playlist.videos.is_empty());
assert_eq!(playlist.ctoken.is_some(), is_long);
@ -323,4 +380,19 @@ mod tests {
let playlist_data = map_playlist(&playlist).unwrap();
insta::assert_yaml_snapshot!(format!("map_playlist_data_{}", name), playlist_data);
}
#[test_log::test(tokio::test)]
async fn t_playlist_cont() {
let rt = RustyTube::new();
let mut playlist = rt
.get_playlist("PLbZIPy20-1pN7mqjckepWF78ndb6ci_qi")
.await
.unwrap();
while playlist.ctoken.is_some() {
rt.get_playlist_cont(&mut playlist).await.unwrap();
}
assert!(playlist.videos.len() > 100);
}
}

View file

@ -0,0 +1,87 @@
use serde::Deserialize;
use serde_with::serde_as;
use serde_with::VecSkipError;
use super::{ContentRenderer, ContentsRenderer, Thumbnails, VideoListItem};
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Channel {
pub contents: Contents,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Contents {
pub two_column_browse_results_renderer: TabsRenderer,
}
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TabsRenderer {
#[serde_as(as = "VecSkipError<_>")]
pub tabs: Vec<TabRendererWrap>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TabRendererWrap {
pub tab_renderer: ContentRenderer<SectionListRendererWrap>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SectionListRendererWrap {
pub section_list_renderer: ContentsRenderer<ItemSectionRendererWrap>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ItemSectionRendererWrap {
pub item_section_renderer: ContentsRenderer<GridRendererWrap>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GridRendererWrap {
pub grid_renderer: GridRenderer,
}
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GridRenderer {
#[serde_as(as = "VecSkipError<_>")]
pub items: Vec<VideoListItem<ChannelVideo>>,
}
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelVideo {
pub video_id: String,
pub thumbnail: Thumbnails,
#[serde_as(as = "crate::serializer::text::Text")]
pub title: String,
#[serde_as(as = "crate::serializer::text::Text")]
pub published_time_text: String,
#[serde_as(as = "crate::serializer::text::Text")]
pub view_count_text: String,
#[serde_as(as = "VecSkipError<_>")]
pub thumbnail_overlays: Vec<TimeOverlayWrap>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TimeOverlayWrap {
pub thumbnail_overlay_time_status_renderer: TimeOverlay,
}
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TimeOverlay {
#[serde_as(as = "crate::serializer::text::Text")]
pub text: String,
}

View file

@ -1,7 +1,9 @@
pub mod channel;
pub mod player;
pub mod playlist;
pub mod playlist_music;
pub use channel::Channel;
pub use player::Player;
pub use playlist::Playlist;
pub use playlist_music::PlaylistMusic;
@ -44,10 +46,19 @@ pub struct Thumbnail {
pub height: u32,
}
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContinuationItemRenderer {
pub continuation_endpoint: ContinuationEndpoint,
pub enum VideoListItem<T> {
#[serde(alias = "playlistVideoRenderer")]
GridVideoRenderer {
#[serde(flatten)]
video: T,
},
#[serde(rename_all = "camelCase")]
ContinuationItemRenderer {
continuation_endpoint: ContinuationEndpoint,
},
}
#[derive(Clone, Debug, Deserialize)]

View file

@ -24,27 +24,15 @@ pub enum PlayabilityStatus {
Ok { live_streamability: Option<Empty> },
/// Video cant be played because of DRM / Geoblock
#[serde(rename_all = "camelCase")]
Unplayable {
reason: String,
// error_screen: Option<ErrorScreen>,
},
Unplayable { reason: String },
/// Age limit / Private video
#[serde(rename_all = "camelCase")]
LoginRequired {
reason: String,
// error_screen: Option<ErrorScreen>
},
LoginRequired { reason: String },
#[serde(rename_all = "camelCase")]
LiveStreamOffline {
reason: String,
// error_screen: Option<ErrorScreen>
},
LiveStreamOffline { reason: String },
/// Video was censored / deleted
#[serde(rename_all = "camelCase")]
Error {
reason: String,
// error_screen: Option<ErrorScreen>
},
Error { reason: String },
}
#[derive(Clone, Debug, Deserialize)]

View file

@ -4,7 +4,7 @@ use serde_with::{json::JsonString, DefaultOnError, VecSkipError};
use crate::serializer::text::{Text, TextLink};
use super::{ContentRenderer, ContentsRenderer, ContinuationEndpoint, Thumbnails, ThumbnailsWrap};
use super::{ContentRenderer, ContentsRenderer, Thumbnails, ThumbnailsWrap, VideoListItem};
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -14,6 +14,14 @@ pub struct Playlist {
pub sidebar: Sidebar,
}
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaylistCont {
#[serde_as(as = "VecSkipError<_>")]
pub on_response_received_actions: Vec<OnResponseReceivedAction>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Contents {
@ -49,21 +57,7 @@ pub struct PlaylistVideoListRenderer {
#[serde(rename_all = "camelCase")]
pub struct PlaylistVideoList {
#[serde_as(as = "VecSkipError<_>")]
pub contents: Vec<PlaylistVideoItem>,
}
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PlaylistVideoItem {
PlaylistVideoRenderer {
#[serde(flatten)]
video: PlaylistVideo,
},
#[serde(rename_all = "camelCase")]
ContinuationItemRenderer {
continuation_endpoint: ContinuationEndpoint,
},
pub contents: Vec<VideoListItem<PlaylistVideo>>,
}
#[serde_as]
@ -152,3 +146,18 @@ pub struct VideoOwner {
#[serde_as(as = "crate::serializer::text::TextLink")]
pub title: TextLink,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OnResponseReceivedAction {
pub append_continuation_items_action: AppendAction,
}
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppendAction {
#[serde_as(as = "VecSkipError<_>")]
pub continuation_items: Vec<VideoListItem<PlaylistVideo>>,
pub target_id: String,
}

View file

@ -0,0 +1,280 @@
#![cfg(test)]
use std::collections::BTreeMap;
use std::path::Path;
use fancy_regex::Regex;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use serde_with::VecSkipError;
use crate::client::ClientType;
use crate::client::ContextYT;
use crate::client::RustyTube;
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct QLanguageMenu {
context: ContextYT,
}
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LanguageMenu {
#[serde_as(as = "VecSkipError<_>")]
actions: Vec<ActionWrap>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ActionWrap {
open_popup_action: OpenPopupAction,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OpenPopupAction {
popup: Popup,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Popup {
multi_page_menu_renderer: MultiPageMenuRenderer<MenuSectionRenderer>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MultiPageMenuRenderer<T> {
sections: Vec<MenuSectionRendererWrap<T>>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MenuSectionRendererWrap<T> {
multi_page_menu_section_renderer: T,
}
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MenuSectionRenderer {
#[serde_as(as = "VecSkipError<_>")]
items: Vec<CompactLinkRendererWrap>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CompactLinkRendererWrap {
compact_link_renderer: CompactLinkRenderer,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CompactLinkRenderer {
icon: Icon,
service_endpoint: ServiceEndpoint<MenuAction>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Icon {
icon_type: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ServiceEndpoint<T> {
signal_service_endpoint: SignalServiceEndpoint<T>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SignalServiceEndpoint<T> {
actions: Vec<T>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MenuAction {
get_multi_page_menu_action: MultiPageMenuAction,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MultiPageMenuAction {
menu: Menu,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Menu {
multi_page_menu_renderer: MultiPageMenuRenderer<ItemSectionRenderer>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ItemSectionRenderer {
items: Vec<LanguageItemWrap>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LanguageItemWrap {
compact_link_renderer: LanguageItem,
}
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LanguageItem {
#[serde_as(as = "crate::serializer::text::Text")]
title: String,
service_endpoint: ServiceEndpoint<LanguageCountryAction>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LanguageCountryAction {
#[serde(alias = "selectCountryCommand")]
select_language_command: LanguageCountryCommand,
}
#[derive(Clone, Debug, Deserialize)]
struct LanguageCountryCommand {
#[serde(alias = "gl")]
hl: String,
}
#[test_log::test(tokio::test)]
async fn generate_locales() {
let (languages, countries) = get_locales().await;
let mut code = "// GENERATED SECTION START //\n".to_owned();
code.push_str("#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]\n");
code.push_str("#[serde(rename_all = \"kebab-case\")]\n");
code.push_str("pub enum Language {\n");
languages.iter().for_each(|(c, n)| {
code.push_str(&format!(" /// {}\n ", n));
if c.contains('-') {
code.push_str(&format!("#[serde(rename=\"{}\")]\n ", c));
}
c.split('-').for_each(|c| {
code.push_str(&format!(
"{}{}",
c[0..1].to_owned().to_uppercase(),
c[1..].to_owned().to_lowercase()
))
});
code.push_str(",\n");
});
code.push_str("}\n\n");
code.push_str("#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]\n");
code.push_str("#[serde(rename_all = \"SCREAMING_SNAKE_CASE\")]\n");
code.push_str("pub enum Country {\n");
countries.iter().for_each(|(c, n)| {
code.push_str(&format!(" /// {}\n", n));
code.push_str(&format!(
" {}{},\n",
c[0..1].to_owned().to_uppercase(),
c[1..].to_owned().to_lowercase()
))
});
code.push_str("}\n");
code.push_str("// GENERATED SECTION END //");
let locale_path = Path::new("src/model/locale.rs");
let src = std::fs::read_to_string(locale_path).unwrap();
let delim_pattern =
Regex::new("// GENERATED SECTION START //\n[^@]*// GENERATED SECTION END //").unwrap();
let new_src = delim_pattern.replace(&src, code);
std::fs::write(locale_path, new_src.as_bytes()).unwrap();
}
async fn get_locales() -> (BTreeMap<String, String>, BTreeMap<String, String>) {
let rt = RustyTube::new();
let client = rt.get_ytclient(ClientType::Desktop);
let context = client.get_context(true).await;
let request_body = QLanguageMenu { context };
let resp = client
.request_builder(Method::POST, "account/account_menu")
.await
.json(&request_body)
.send()
.await
.unwrap()
.error_for_status()
.unwrap();
let language_menu = resp.json::<LanguageMenu>().await.unwrap();
let lm_section = &language_menu.actions[0]
.open_popup_action
.popup
.multi_page_menu_renderer
.sections
.iter()
.find(|s| s.multi_page_menu_section_renderer.items.len() >= 2)
.unwrap();
let lang_section = lm_section
.multi_page_menu_section_renderer
.items
.iter()
.find(|s| s.compact_link_renderer.icon.icon_type == "TRANSLATE")
.unwrap();
let country_section = lm_section
.multi_page_menu_section_renderer
.items
.iter()
.find(|s| s.compact_link_renderer.icon.icon_type == "LANGUAGE")
.unwrap();
let languages = map_language_section(lang_section);
let countries = map_language_section(country_section);
(languages, countries)
}
fn map_language_section(section: &CompactLinkRendererWrap) -> BTreeMap<String, String> {
section
.compact_link_renderer
.service_endpoint
.signal_service_endpoint
.actions[0]
.get_multi_page_menu_action
.menu
.multi_page_menu_renderer
.sections[0]
.multi_page_menu_section_renderer
.items
.iter()
.map(|i| {
(
i.compact_link_renderer
.service_endpoint
.signal_service_endpoint
.actions[0]
.select_language_command
.hl
.to_owned(),
i.compact_link_renderer.title.to_owned(),
)
})
.collect::<BTreeMap<_, _>>()
}

View file

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

View file

@ -0,0 +1,130 @@
#![cfg(test)]
use std::{
collections::{BTreeMap, HashSet},
fs::File,
path::Path,
};
use futures::{stream, StreamExt};
use reqwest::Method;
use serde::Serialize;
use crate::{
client::{response, ClientType, ContextYT, RustyTube},
model::{Country, Language},
timeago,
};
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct QChannel {
context: ContextYT,
browse_id: String,
params: String,
}
async fn get_channel_datestrings(rp: &RustyTube, channel_id: &str) -> Vec<String> {
let client = rp.get_ytclient(ClientType::Desktop);
let context = client.get_context(true).await;
let request_body = QChannel {
context,
browse_id: channel_id.to_owned(),
params: "EgZ2aWRlb3PyBgQKAjoA".to_owned(),
};
let resp = client
.request_builder(Method::POST, "browse")
.await
.json(&request_body)
.send()
.await
.unwrap()
.error_for_status()
.unwrap();
let channel_response = resp.json::<response::Channel>().await.unwrap();
channel_response
.contents
.two_column_browse_results_renderer
.tabs[0]
.tab_renderer
.content
.section_list_renderer
.contents[0]
.item_section_renderer
.contents[0]
.grid_renderer
.items
.iter()
.filter_map(|itm| match itm {
response::VideoListItem::GridVideoRenderer { video } => {
Some(video.published_time_text.to_owned())
}
response::VideoListItem::ContinuationItemRenderer { .. } => None,
})
.collect::<Vec<_>>()
}
#[test_log::test(tokio::test)]
async fn download_timeago_testfiles() {
let json_path = Path::new("testfiles/date/timeago.json").to_path_buf();
if json_path.exists() {
return;
}
let channel_ids = [
"UCeY0bbntWzzVIaj2z3QigXg",
"UCcmpeVbSSQlZRvHfdC-CRwg",
"UC65afEgL62PGFWXY7n6CUbA",
"UCEOXxzW2vU0P-0THehuIIeg",
];
// Get strings of all languages
let mut lang_strings: BTreeMap<Language, Vec<String>> = BTreeMap::new();
for lang in timeago::LANGUAGES {
let rp = RustyTube::new_with_ua(lang, Country::Us, None);
let strings = stream::iter(channel_ids)
.map(|id| get_channel_datestrings(&rp, id))
.buffered(4)
.collect::<Vec<_>>()
.await
.into_iter()
.flatten()
.collect::<Vec<_>>();
lang_strings.insert(lang, strings);
}
let mut en_strings_uniq: HashSet<&str> = HashSet::new();
let mut uniq_ids: HashSet<usize> = HashSet::new();
lang_strings[&Language::En]
.iter()
.enumerate()
.for_each(|(n, s)| {
if en_strings_uniq.insert(s) {
uniq_ids.insert(n);
}
});
let strings_map = lang_strings
.iter()
.map(|(lang, strings)| {
(
lang,
strings
.iter()
.enumerate()
.filter(|(n, _)| uniq_ids.contains(n))
.map(|(_, s)| s)
.collect::<Vec<_>>(),
)
})
.collect::<BTreeMap<_, _>>();
let file = File::create(json_path).unwrap();
serde_json::to_writer_pretty(file, &strings_map).unwrap();
}

View file

@ -2,6 +2,8 @@
source: src/client/playlist.rs
expression: playlist_data
---
id: PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ
name: Die schönsten deutschen Lieder | Beliebteste Lieder | Beste Deutsche Musik 2022
videos:
- id: Bkj3IVIO2Os
title: Stereoact feat. Kerstin Ott - Die Immer Lacht (Official Video HD)
@ -1905,7 +1907,6 @@ videos:
name: KMNGANG
n_videos: 495
ctoken: 4qmFsgJhEiRWTFBMNWREeDY4MVQ0YlI3WkYxSXVXek92MW9tbFJiRTdQaUoaFENBRjZCbEJVT2tOSFdRJTNEJTNEmgIiUEw1ZER4NjgxVDRiUjdaRjFJdVd6T3Yxb21sUmJFN1BpSg%3D%3D
name: Die schönsten deutschen Lieder | Beliebteste Lieder | Beste Deutsche Musik 2022
thumbnails:
- url: "https://i.ytimg.com/vi/Bkj3IVIO2Os/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLBShlXfy9oWvTy2ntoHxmuhBlZP3g"
width: 168
@ -1923,4 +1924,5 @@ description: ~
channel:
id: UCIekuFeMaV78xYfvpmoCnPg
name: Best Music
last_update: ~

View file

@ -2,6 +2,8 @@
source: src/client/playlist.rs
expression: playlist_data
---
id: PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe
name: Minecraft SHINE
videos:
- id: X82TrticM4A
title: Minecraft SHINE (Trailer)
@ -1259,7 +1261,6 @@ videos:
name: Chaosflo44
n_videos: 66
ctoken: ~
name: Minecraft SHINE
thumbnails:
- url: "https://i.ytimg.com/vi/X82TrticM4A/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLBeSNDZEaBoP0KX9_Ayn3VO3X8rkw"
width: 168
@ -1277,4 +1278,5 @@ description: "SHINE - Survival Hardcore in New Environment: Auf einem Server mac
channel:
id: UCQM0bS4_04-Y4JuYrgmnpZQ
name: Chaosflo44
last_update: ~

View file

@ -2,6 +2,8 @@
source: src/client/playlist.rs
expression: playlist_data
---
id: RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk
name: Easy Pop
videos:
- id: psuRGfAaju4
title: Owl City - Fireflies (Official Music Video)
@ -1848,7 +1850,6 @@ videos:
name: Diaven - Topic
n_videos: 97
ctoken: ~
name: Easy Pop
thumbnails:
- url: "https://i9.ytimg.com/s_p/RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk/mqdefault.jpg?sqp=CPDmr5gGir7X7AMGCLaHmpgG&rs=AOn4CLDg9xLeJDPeMtbWfp19VFd6vCQzqQ&v=1661371318"
width: 180
@ -1861,4 +1862,5 @@ thumbnails:
height: 1200
description: ~
channel: ~
last_update: ~

View file

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

View file

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

View file

@ -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
View 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,
/// Ozbek
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))
}
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff