Skip to content

Commit 9ca0567

Browse files
committed
27.07.2024
* Added age restricted videos support
1 parent c017540 commit 9ca0567

File tree

5 files changed

+165
-24
lines changed

5 files changed

+165
-24
lines changed

Cargo.toml

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,34 +26,34 @@ reqwest = { version = "0.12.5", features = [
2626
"cookies",
2727
"gzip",
2828
], default-features = false }
29-
scraper = "0.19.0"
30-
serde = "1.0.202"
31-
serde_json = "1.0.117"
29+
scraper = "0.19.1"
30+
serde = "1.0.204"
31+
serde_json = "1.0.120"
3232
serde_qs = "0.13.0"
33-
regex = "1.10.3"
34-
url = "2.5.0"
33+
regex = "1.10.5"
34+
url = "2.5.2"
3535
urlencoding = "2.1.3"
36-
thiserror = "1.0.60"
37-
derive_more = "0.99.17"
36+
thiserror = "1.0.63"
37+
derive_more = "0.99.18"
3838
derivative = "2.2.0"
3939
once_cell = "1.19.0"
40-
tokio = { version = "1.37.0", default-features = false, features = ["sync"] }
40+
tokio = { version = "1.39.2", default-features = false, features = ["sync"] }
4141
rand = "0.8.5"
4242
reqwest-middleware = { version = "0.3.2", features = ["json"] }
4343
reqwest-retry = "0.6.0"
4444
m3u8-rs = "6.0.0"
45-
async-trait = "0.1.80"
45+
async-trait = "0.1.81"
4646
aes = "0.8.4"
4747
cbc = { version = "0.1.2", features = ["std"] }
4848
hex = "0.4.3"
4949
boa_engine = "0.17.3"
5050
mime = "0.3.17"
51-
bytes = "1.6.0"
51+
bytes = "1.6.1"
5252
flame = { version = "0.2.2", optional = true }
5353
flamer = { version = "0.5.0", optional = true }
5454

5555
[dev-dependencies]
56-
tokio = { version = "1.37.0", features = ["full"] }
56+
tokio = { version = "1.39.2", features = ["full"] }
5757

5858
[features]
5959
default = ["search", "live", "default-tls"]

src/blocking/search/youtube.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ impl Playlist {
9898
/// Get next chunk of videos from playlist and return fetched [`Video`] array.
9999
/// - If limit is [`None`] it will be [`u64::MAX`]
100100
/// - If [`Playlist`] is coming from [`SearchResult`] this function always return empty [`Vec<Video>`]!
101+
///
101102
/// to use this function with [`SearchResult`] follow example
102103
///
103104
/// # Example
@@ -131,6 +132,7 @@ impl Playlist {
131132
/// Try to fetch all playlist videos and return [`Playlist`].
132133
/// - If limit is [`None`] it will be [`u64::MAX`]
133134
/// - If [`Playlist`] is coming from [`SearchResult`] this function always return [`Playlist`] with empty [`Vec<Video>`]!
135+
///
134136
/// to use this function with [`SearchResult`] follow example
135137
///
136138
/// # Example

src/info.rs

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
use once_cell::sync::Lazy;
12
use reqwest::{
2-
header::{HeaderMap, HeaderValue, COOKIE},
3+
header::{HeaderMap, HeaderName, HeaderValue, COOKIE},
34
Client,
45
};
56
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
@@ -17,11 +18,12 @@ use crate::{
1718
constants::BASE_URL,
1819
info_extras::{get_media, get_related_videos},
1920
stream::{NonLiveStream, NonLiveStreamOptions, Stream},
20-
structs::{PlayerResponse, VideoError, VideoInfo, VideoOptions},
21+
structs::{PlayerResponse, VideoError, VideoInfo, VideoOptions, YTConfig},
2122
utils::{
2223
between, choose_format, clean_video_details, get_functions, get_html, get_html5player,
23-
get_random_v6_ip, get_video_id, is_not_yet_broadcasted, is_play_error, is_private_video,
24-
is_rental, parse_live_video_formats, parse_video_formats, sort_formats,
24+
get_random_v6_ip, get_video_id, get_ytconfig, is_age_restricted_from_html,
25+
is_not_yet_broadcasted, is_play_error, is_private_video, is_rental,
26+
parse_live_video_formats, parse_video_formats, sort_formats,
2527
},
2628
};
2729

@@ -120,7 +122,7 @@ impl Video {
120122

121123
let response = get_html(client, url_parsed.as_str(), None).await?;
122124

123-
let (player_response, initial_response): (PlayerResponse, serde_json::Value) = {
125+
let (mut player_response, initial_response): (PlayerResponse, serde_json::Value) = {
124126
let document = Html::parse_document(&response);
125127
let scripts_selector = Selector::parse("script").unwrap();
126128
let player_response_string = document
@@ -158,14 +160,23 @@ impl Video {
158160
return Err(VideoError::VideoNotFound);
159161
}
160162

161-
if is_private_video(&player_response) {
163+
let is_age_restricted = is_age_restricted_from_html(&player_response, &response);
164+
165+
if is_private_video(&player_response) && !is_age_restricted {
162166
return Err(VideoError::VideoIsPrivate);
163167
}
164168

165-
if player_response.streaming_data.is_none()
166-
|| is_rental(&player_response)
167-
|| is_not_yet_broadcasted(&player_response)
168-
{
169+
if is_age_restricted {
170+
let embed_ytconfig = self.get_embeded_ytconfig(&response).await?;
171+
172+
let player_response_new =
173+
serde_json::from_str::<PlayerResponse>(&embed_ytconfig).unwrap();
174+
175+
player_response.streaming_data = player_response_new.streaming_data;
176+
player_response.storyboards = player_response_new.storyboards;
177+
}
178+
179+
if is_rental(&player_response) || is_not_yet_broadcasted(&player_response) {
169180
return Err(VideoError::VideoSourceNotFound);
170181
}
171182

@@ -456,6 +467,68 @@ impl Video {
456467
pub(crate) fn get_options(&self) -> VideoOptions {
457468
self.options.clone()
458469
}
470+
471+
#[cfg_attr(feature = "performance_analysis", flamer::flame)]
472+
async fn get_embeded_ytconfig(&self, html: &str) -> Result<String, VideoError> {
473+
let ytcfg = get_ytconfig(html)?;
474+
475+
// This client can access age restricted videos (unless the uploader has disabled the 'allow embedding' option)
476+
// See: https://github.com/yt-dlp/yt-dlp/blob/28d485714fef88937c82635438afba5db81f9089/yt_dlp/extractor/youtube.py#L231
477+
let query = serde_json::json!({
478+
"context": {
479+
"client": {
480+
"clientName": "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
481+
"clientVersion": "2.0",
482+
"hl": "en",
483+
"clientScreen": "EMBED",
484+
},
485+
"thirdParty": {
486+
"embedUrl": "https://google.com",
487+
},
488+
},
489+
"playbackContext": {
490+
"contentPlaybackContext": {
491+
"signatureTimestamp": ytcfg.sts.unwrap_or(0),
492+
"html5Preference": "HTML5_PREF_WANTS",
493+
},
494+
},
495+
"videoId": self.get_video_id(),
496+
});
497+
498+
static CONFIGS: Lazy<(HeaderMap, &str)> = Lazy::new(|| {
499+
use std::str::FromStr;
500+
501+
(HeaderMap::from_iter([
502+
(HeaderName::from_str("content-type").unwrap(), HeaderValue::from_str("application/json").unwrap()),
503+
(HeaderName::from_str("X-Youtube-Client-Name").unwrap(), HeaderValue::from_str("85").unwrap()),
504+
(HeaderName::from_str("X-Youtube-Client-Version").unwrap(), HeaderValue::from_str("2.0").unwrap()),
505+
(HeaderName::from_str("Origin").unwrap(), HeaderValue::from_str("https://www.youtube.com").unwrap()),
506+
(HeaderName::from_str("User-Agent").unwrap(), HeaderValue::from_str("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3513.0 Safari/537.36").unwrap()),
507+
(HeaderName::from_str("Referer").unwrap(), HeaderValue::from_str("https://www.youtube.com/").unwrap()),
508+
(HeaderName::from_str("Accept").unwrap(), HeaderValue::from_str("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8").unwrap()),
509+
(HeaderName::from_str("Accept-Language").unwrap(), HeaderValue::from_str("en-US,en;q=0.5").unwrap()),
510+
(HeaderName::from_str("Accept-Encoding").unwrap(), HeaderValue::from_str("gzip, deflate").unwrap()),
511+
]),"AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8")
512+
});
513+
514+
let response = self
515+
.client
516+
.post("https://www.youtube.com/youtubei/v1/player")
517+
.headers(CONFIGS.0.clone())
518+
.query(&[("key", CONFIGS.1)])
519+
.json(&query)
520+
.send()
521+
.await
522+
.map_err(VideoError::ReqwestMiddleware)?;
523+
524+
let response = response
525+
.error_for_status()
526+
.map_err(VideoError::Reqwest)?
527+
.text()
528+
.await?;
529+
530+
Ok(response)
531+
}
459532
}
460533

461534
async fn get_m3u8(

src/structs.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -987,3 +987,9 @@ pub struct ErrorScreen {
987987
#[serde(rename = "playerLegacyDesktopYpcOfferRenderer")]
988988
pub player_legacy_desktop_ypc_offer_renderer: Option<String>,
989989
}
990+
991+
#[derive(Clone, Debug, Serialize, Deserialize)]
992+
pub struct YTConfig {
993+
#[serde(rename = "STS")]
994+
pub sts: Option<u64>,
995+
}

src/utils.rs

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use boa_engine::{Context, Source};
22
use once_cell::sync::Lazy;
33
use rand::Rng;
44
use regex::Regex;
5+
use scraper::{Html, Selector};
56
use serde::{Deserialize, Serialize};
67
use std::{
78
borrow::Cow,
@@ -20,7 +21,7 @@ use crate::{
2021
info_extras::{get_author, get_chapters, get_dislikes, get_likes, get_storyboards},
2122
structs::{
2223
Embed, PlayerResponse, StreamingDataFormat, StringUtils, VideoDetails, VideoError,
23-
VideoFormat, VideoOptions, VideoQuality, VideoSearchOptions,
24+
VideoFormat, VideoOptions, VideoQuality, VideoSearchOptions, YTConfig,
2425
},
2526
};
2627

@@ -500,7 +501,7 @@ fn ncode(
500501
n_transfrom_cache: &mut HashMap<String, String>,
501502
) -> String {
502503
let components: serde_json::value::Map<String, serde_json::Value> =
503-
serde_qs::from_str(&decode(url).unwrap_or(Cow::Borrowed(url))).unwrap();
504+
serde_qs::from_str(&decode(url).unwrap_or(Cow::Borrowed(url))).unwrap_or_default();
504505

505506
let n_transform_value = match components.get("n").and_then(serde_json::Value::as_str) {
506507
Some(val) if !n_transform_script_string.1.is_empty() => val,
@@ -534,7 +535,7 @@ fn ncode(
534535
.and_then(|result| {
535536
result
536537
.as_string()
537-
.map(|js_str| js_str.to_std_string().unwrap())
538+
.map(|js_str| js_str.to_std_string().unwrap_or_default())
538539
})
539540
}
540541

@@ -888,6 +889,54 @@ pub fn is_age_restricted(media: &serde_json::Value) -> bool {
888889
age_restricted
889890
}
890891

892+
#[cfg_attr(feature = "performance_analysis", flamer::flame)]
893+
pub fn is_age_restricted_from_html(player_response: &PlayerResponse, html: &str) -> bool {
894+
if !player_response
895+
.micro_format
896+
.as_ref()
897+
.and_then(|x| x.player_micro_format_renderer.clone())
898+
.and_then(|x| x.is_family_safe)
899+
.unwrap_or(true)
900+
{
901+
return true;
902+
}
903+
904+
let document = Html::parse_document(html);
905+
let metas_selector = Selector::parse("meta").unwrap();
906+
907+
// <meta property="og:restrictions:age" content="18+">
908+
let og_restrictions_age = document
909+
.select(&metas_selector)
910+
.filter(|x| {
911+
x.attr("itemprop")
912+
.or(x.attr("name"))
913+
.or(x.attr("property"))
914+
.or(x.attr("id"))
915+
.or(x.attr("http-equiv"))
916+
== Some("og:restrictions:age")
917+
})
918+
.map(|x| x.attr("content").unwrap_or("").to_string())
919+
.next()
920+
.unwrap_or(String::from(""));
921+
922+
// <meta itemprop="isFamilyFriendly" content="true">
923+
let is_family_friendly = document
924+
.select(&metas_selector)
925+
.filter(|x| {
926+
x.attr("itemprop")
927+
.or(x.attr("name"))
928+
.or(x.attr("property"))
929+
.or(x.attr("id"))
930+
.or(x.attr("http-equiv"))
931+
== Some("isFamilyFriendly")
932+
})
933+
.map(|x| x.attr("content").unwrap_or("").to_string())
934+
.next()
935+
.unwrap_or(String::from(""));
936+
937+
is_family_friendly == "false" || og_restrictions_age == "18+"
938+
}
939+
891940
#[cfg_attr(feature = "performance_analysis", flamer::flame)]
892941
pub fn is_rental(player_response: &PlayerResponse) -> bool {
893942
if player_response.playability_status.is_none() {
@@ -946,6 +995,17 @@ pub fn is_private_video(player_response: &PlayerResponse) -> bool {
946995
.unwrap_or(false)
947996
}
948997

998+
pub fn get_ytconfig(html: &str) -> Result<YTConfig, VideoError> {
999+
static PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r#"ytcfg\.set\((\{.*\})\)"#).unwrap());
1000+
match PATTERN.captures(html) {
1001+
Some(c) => Ok(
1002+
serde_json::from_str::<YTConfig>(c.get(1).map_or("", |m| m.as_str()))
1003+
.map_err(|_x| VideoError::VideoSourceNotFound)?,
1004+
),
1005+
None => Err(VideoError::VideoSourceNotFound),
1006+
}
1007+
}
1008+
9491009
type CacheFunctions = Lazy<RwLock<Option<(String, Vec<(String, String)>)>>>;
9501010
static FUNCTIONS: CacheFunctions = Lazy::new(|| RwLock::new(None));
9511011

0 commit comments

Comments
 (0)