use crate::*;
use alux_ext::ext;
use rsynko_media::*;
use std::fmt::{Display, Formatter, Result as FormatResult};
use thiserror::Error;
#[ext(name = YoutubeExtractionExt)]
pub impl<This, RequestError, ResponseError, ChallengeError> This
where
This: YoutubeRequestAlg
+ YoutubeClientAlg
+ YoutubeProgramAlg
+ YoutubeRequestBytesAlg<Error = RequestError>
+ YoutubeResponseAlg<Error = ResponseError>
+ YoutubeChallengeAlg<Error = ChallengeError>
+ YoutubeSolutionAlg
+ YoutubeUrlAlg
+ MetadataAlg
+ FormatAlg
+ ExtractionAlg,
{
fn extract_youtube(
&self,
url: &str,
) -> Result<This::Extraction, YoutubeError<RequestError, ResponseError, ChallengeError>> {
let id = youtube_id(url).ok_or_else(|| YoutubeError::Url(url.to_owned()))?;
let watch_request = self.watch_request(url);
let page_bytes = self.youtube_request_bytes(&watch_request).map_err(YoutubeError::Request)?;
let page = self.decode_youtube_watch(&page_bytes).map_err(YoutubeError::Response)?;
if !page.has_player_response {
return Err(YoutubeError::MissingPlayerResponse);
}
let api_key = page.api_key.ok_or(YoutubeError::MissingApiKey)?;
let program = match page.player_url {
Some(url) => {
let request = self.player_program_request(url);
let bytes = self.youtube_request_bytes(&request).map_err(YoutubeError::Request)?;
String::from_utf8_lossy(&bytes).into_owned()
}
None => String::new(),
};
let player = self.granted_catalog(&id, &api_key, page.visitor_data.as_deref(), &program)?;
if player.status.as_deref() != Some("OK") {
return Err(YoutubeError::Unavailable(player.reason.unwrap_or_else(|| "video unavailable".to_owned())));
}
let unreadable = player.unreadable;
let mut ranked = player.formats;
ranked.sort_by_key(|format| (format.height.unwrap_or(0), format.bitrate.unwrap_or(0)));
if ranked.is_empty() {
return Err(YoutubeError::NoDirectFormat);
}
let grants = self.grant_formats(&program, &ranked).map_err(YoutubeError::Challenge)?;
let mut formats = Vec::new();
let mut withheld = None;
let mut withheld_count = 0_usize;
let mut throttled_count = 0_usize;
for (format, grant) in ranked.iter().zip(grants) {
match grant {
YoutubeGrant::Granted { url, .. } => {
formats.push(self.youtube_format(format.clone(), url));
}
YoutubeGrant::Throttled { url, .. } => {
throttled_count += 1;
formats.push(self.youtube_format(format.clone(), url));
}
YoutubeGrant::Withheld { challenge, .. } => {
withheld_count += 1;
withheld = Some(withheld_reason(&challenge).max(withheld.unwrap_or(WithheldReason::Throttle)));
}
}
}
if formats.is_empty() {
return Err(YoutubeError::Withheld(withheld.unwrap_or(WithheldReason::Throttle)));
}
let title = player.title.or(page.title);
let mut fields = vec![
(YOUTUBE_DESCRIBED.to_owned(), self.integer_metadata(i64::try_from(ranked.len()).unwrap_or(i64::MAX))),
(YOUTUBE_WITHHELD.to_owned(), self.integer_metadata(i64::try_from(withheld_count).unwrap_or(i64::MAX))),
(YOUTUBE_THROTTLED.to_owned(), self.integer_metadata(i64::try_from(throttled_count).unwrap_or(i64::MAX))),
(YOUTUBE_UNREADABLE.to_owned(), self.integer_metadata(i64::try_from(unreadable).unwrap_or(i64::MAX))),
];
if let Some(title) = title {
fields.push(("title".to_owned(), self.string_metadata(title)));
}
Ok(self.media(id, self.metadata(fields), formats))
}
fn granted_catalog(
&self,
id: &str,
api_key: &str,
visitor: Option<&str>,
program: &str,
) -> Result<YoutubePlayer, YoutubeError<RequestError, ResponseError, ChallengeError>> {
let mut catalog = YoutubePlayer { status: None, reason: None, title: None, formats: Vec::new(), unreadable: 0 };
let clients: Vec<String> = self.player_clients().map(str::to_owned).collect();
for client in clients {
let claim =
PlayerClaim { client, visitor: visitor.map(str::to_owned), timestamp: self.program_timestamp(program) };
let request = self.player_request(id, api_key, &claim);
let bytes = self.youtube_request_bytes(&request).map_err(YoutubeError::Request)?;
let granted = self.decode_youtube_player(&bytes).map_err(YoutubeError::Response)?;
if granted.status.as_deref() != Some("OK") {
catalog.status = catalog.status.or(granted.status);
catalog.reason = catalog.reason.or(granted.reason);
continue;
}
catalog.status = Some("OK".to_owned());
catalog.reason = None;
catalog.title = catalog.title.or(granted.title);
catalog.unreadable += granted.unreadable;
for format in granted.formats {
if !catalog.formats.iter().any(|held| held.id == format.id) {
catalog.formats.push(format);
}
}
}
Ok(catalog)
}
fn youtube_format(&self, format: YoutubeFormat, url: String) -> This::Format {
let mut fields = vec![
(FORMAT_SOURCE.to_owned(), self.string_metadata(url)),
(FORMAT_HAS_AUDIO.to_owned(), self.boolean_metadata(format.has_audio)),
(FORMAT_HAS_VIDEO.to_owned(), self.boolean_metadata(format.has_video)),
];
if let Some(value) = format.extension {
fields.push((FORMAT_EXTENSION.to_owned(), self.string_metadata(value)));
}
if let Some(value) = format.quality {
fields.push(("quality".to_owned(), self.string_metadata(value)));
}
for (key, value) in [
("width", format.width),
("height", format.height),
("bitrate", format.bitrate),
("content_length", format.content_length),
] {
if let Some(value) = value {
fields.push((key.to_owned(), self.integer_metadata(value)));
}
}
if let Some(value) = format.codecs {
fields.push(("codecs".to_owned(), self.string_metadata(value)));
}
self.format(format.id, self.metadata(fields))
}
}
#[derive(Debug, Error)]
pub enum YoutubeError<RequestError, ResponseError, ChallengeError> {
#[error("malformed Youtube URL: {0}")]
Url(String),
#[error("Youtube request failed: {0}")]
Request(RequestError),
#[error("Youtube response decoding failed: {0}")]
Response(ResponseError),
#[error("the Youtube page contains no parseable initial player response")]
MissingPlayerResponse,
#[error("the Youtube page contains no InnerTube API key")]
MissingApiKey,
#[error("Youtube reports: {0}")]
Unavailable(String),
#[error("the player response describes no audio or video format")]
NoDirectFormat,
#[error("Youtube challenge resolution failed: {0}")]
Challenge(ChallengeError),
#[error("every described format is withheld behind an unresolved {0} challenge")]
Withheld(WithheldReason),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum WithheldReason {
Throttle,
Signature,
}
impl Display for WithheldReason {
fn fmt(&self, formatter: &mut Formatter<'_>) -> FormatResult {
formatter.write_str(match self {
Self::Signature => "signature",
Self::Throttle => "throttling",
})
}
}
fn withheld_reason(challenge: &YoutubeChallenge) -> WithheldReason {
match challenge {
YoutubeChallenge::Signature(_) => WithheldReason::Signature,
YoutubeChallenge::Throttle(_) => WithheldReason::Throttle,
}
}
pub const YOUTUBE_DESCRIBED: &str = "youtube_described";
pub const YOUTUBE_WITHHELD: &str = "youtube_withheld";
pub const YOUTUBE_THROTTLED: &str = "youtube_throttled";
pub const YOUTUBE_UNREADABLE: &str = "youtube_unreadable";
#[ext(name = YoutubeNotesExt)]
pub impl<This> This
where
This: MetadataViewAlg,
{
fn granting_notes(&self, metadata: &This::Metadata) -> Vec<String> {
let counted = |key| self.metadata_number(metadata, key);
let mut notes = Vec::new();
if let Some(described) = counted(YOUTUBE_DESCRIBED) {
notes.push(format!("the player response described {described} formats"));
}
if let Some(unreadable) = counted(YOUTUBE_UNREADABLE).filter(|count| *count > 0) {
notes.push(format!("{unreadable} described formats state no location this interpreter can read"));
}
if let Some(withheld) = counted(YOUTUBE_WITHHELD) {
notes.push(if withheld == 0 {
"every signature was answered by the player program".to_owned()
} else {
format!("{withheld} formats withheld behind an unanswered signature")
});
}
if let Some(throttled) = counted(YOUTUBE_THROTTLED).filter(|count| *count > 0) {
notes.push(format!("{throttled} formats served at a throttled rate"));
}
notes
}
}
const SIGNED_URL_MARK: &str = " for url (";
const FORBIDDEN: &str = "403 Forbidden";
#[must_use]
pub fn media_failure(detail: &str) -> String {
let stated = detail
.find(SIGNED_URL_MARK)
.map_or_else(|| detail.to_owned(), |index| format!("{}; signed media URL redacted", &detail[..index]));
if stated.contains(FORBIDDEN) {
return format!("media request rejected: HTTP 403 Forbidden\n{stated}");
}
stated
}
#[must_use]
pub fn youtube_id(input: &str) -> Option<String> {
let without_scheme = input.strip_prefix("https://").or_else(|| input.strip_prefix("http://"))?;
let (authority, suffix) =
without_scheme.split_once('/').map_or((without_scheme, ""), |(authority, suffix)| (authority, suffix));
let host = authority.split('@').next_back()?.split(':').next()?;
match host {
"youtu.be" | "www.youtu.be" => {
suffix.split(['?', '#', '/']).next().filter(|id| !id.is_empty()).map(str::to_owned)
}
"youtube.com" | "www.youtube.com" | "m.youtube.com" => {
if let Some(query) = suffix.strip_prefix("watch?") {
query.split('&').find_map(|pair| {
let (key, value) = pair.split_once('=')?;
(key == "v" && !value.is_empty()).then(|| value.to_owned())
})
} else {
suffix.split(['?', '#']).next()?.split('/').nth(1).filter(|id| !id.is_empty()).map(str::to_owned)
}
}
_ => None,
}
}