1use crate::*;
2use alux_ext::ext;
3use rsynko_media::*;
4use std::fmt::{Display, Formatter, Result as FormatResult};
5use thiserror::Error;
6
7#[ext(name = YoutubeExtractionExt)]
9pub impl<This, RequestError, ResponseError, ChallengeError> This
10where
11 This: YoutubeRequestAlg
12 + YoutubeClientAlg
13 + YoutubeProgramAlg
14 + YoutubeRequestBytesAlg<Error = RequestError>
15 + YoutubeResponseAlg<Error = ResponseError>
16 + YoutubeChallengeAlg<Error = ChallengeError>
17 + YoutubeSolutionAlg
18 + YoutubeUrlAlg
19 + MetadataAlg
20 + FormatAlg
21 + ExtractionAlg,
22{
23 fn extract_youtube(
29 &self,
30 url: &str,
31 ) -> Result<This::Extraction, YoutubeError<RequestError, ResponseError, ChallengeError>> {
32 let id = youtube_id(url).ok_or_else(|| YoutubeError::Url(url.to_owned()))?;
33 let watch_request = self.watch_request(url);
34 let page_bytes = self
35 .youtube_request_bytes(&watch_request)
36 .map_err(YoutubeError::Request)?;
37 let page = self
38 .decode_youtube_watch(&page_bytes)
39 .map_err(YoutubeError::Response)?;
40 if !page.has_player_response {
41 return Err(YoutubeError::MissingPlayerResponse);
42 }
43 let api_key = page.api_key.ok_or(YoutubeError::MissingApiKey)?;
44 let program = match page.player_url {
47 Some(url) => {
48 let request = self.player_program_request(url);
49 let bytes = self
50 .youtube_request_bytes(&request)
51 .map_err(YoutubeError::Request)?;
52 String::from_utf8_lossy(&bytes).into_owned()
53 }
54 None => String::new(),
55 };
56 let player = self.granted_catalog(&id, &api_key, page.visitor_data.as_deref(), &program)?;
57 if player.status.as_deref() != Some("OK") {
58 return Err(YoutubeError::Unavailable(
59 player
60 .reason
61 .unwrap_or_else(|| "video unavailable".to_owned()),
62 ));
63 }
64 let unreadable = player.unreadable;
65 let mut ranked = player.formats;
66 ranked.sort_by_key(|format| (format.height.unwrap_or(0), format.bitrate.unwrap_or(0)));
67 if ranked.is_empty() {
68 return Err(YoutubeError::NoDirectFormat);
69 }
70 let grants = self
71 .grant_formats(&program, &ranked)
72 .map_err(YoutubeError::Challenge)?;
73 let mut formats = Vec::new();
74 let mut withheld = None;
75 let mut withheld_count = 0_usize;
76 let mut throttled_count = 0_usize;
77 for (format, grant) in ranked.iter().zip(grants) {
78 match grant {
79 YoutubeGrant::Granted { url, .. } => {
82 formats.push(self.youtube_format(format.clone(), url));
83 }
84 YoutubeGrant::Throttled { url, .. } => {
85 throttled_count += 1;
86 formats.push(self.youtube_format(format.clone(), url));
87 }
88 YoutubeGrant::Withheld { challenge, .. } => {
89 withheld_count += 1;
90 withheld = Some(
91 withheld_reason(&challenge)
92 .max(withheld.unwrap_or(WithheldReason::Throttle)),
93 );
94 }
95 }
96 }
97 if formats.is_empty() {
98 return Err(YoutubeError::Withheld(
99 withheld.unwrap_or(WithheldReason::Throttle),
100 ));
101 }
102 let title = player.title.or(page.title);
103 let mut fields = vec![
104 (
105 YOUTUBE_DESCRIBED.to_owned(),
106 self.integer_metadata(i64::try_from(ranked.len()).unwrap_or(i64::MAX)),
107 ),
108 (
109 YOUTUBE_WITHHELD.to_owned(),
110 self.integer_metadata(i64::try_from(withheld_count).unwrap_or(i64::MAX)),
111 ),
112 (
113 YOUTUBE_THROTTLED.to_owned(),
114 self.integer_metadata(i64::try_from(throttled_count).unwrap_or(i64::MAX)),
115 ),
116 (
117 YOUTUBE_UNREADABLE.to_owned(),
118 self.integer_metadata(i64::try_from(unreadable).unwrap_or(i64::MAX)),
119 ),
120 ];
121 if let Some(title) = title {
122 fields.push(("title".to_owned(), self.string_metadata(title)));
123 }
124 Ok(self.media(id, self.metadata(fields), formats))
125 }
126
127 fn granted_catalog(
137 &self,
138 id: &str,
139 api_key: &str,
140 visitor: Option<&str>,
141 program: &str,
142 ) -> Result<YoutubePlayer, YoutubeError<RequestError, ResponseError, ChallengeError>> {
143 let mut catalog = YoutubePlayer {
144 status: None,
145 reason: None,
146 title: None,
147 formats: Vec::new(),
148 unreadable: 0,
149 };
150 let clients: Vec<String> = self.player_clients().map(str::to_owned).collect();
151 for client in clients {
152 let claim = PlayerClaim {
153 client,
154 visitor: visitor.map(str::to_owned),
155 timestamp: self.program_timestamp(program),
156 };
157 let request = self.player_request(id, api_key, &claim);
158 let bytes = self
159 .youtube_request_bytes(&request)
160 .map_err(YoutubeError::Request)?;
161 let granted = self
162 .decode_youtube_player(&bytes)
163 .map_err(YoutubeError::Response)?;
164 if granted.status.as_deref() != Some("OK") {
165 catalog.status = catalog.status.or(granted.status);
166 catalog.reason = catalog.reason.or(granted.reason);
167 continue;
168 }
169 catalog.status = Some("OK".to_owned());
170 catalog.reason = None;
171 catalog.title = catalog.title.or(granted.title);
172 catalog.unreadable += granted.unreadable;
173 for format in granted.formats {
174 if !catalog.formats.iter().any(|held| held.id == format.id) {
175 catalog.formats.push(format);
176 }
177 }
178 }
179 Ok(catalog)
180 }
181
182 fn youtube_format(&self, format: YoutubeFormat, url: String) -> This::Format {
184 let mut fields = vec![
185 (FORMAT_SOURCE.to_owned(), self.string_metadata(url)),
186 (
187 FORMAT_HAS_AUDIO.to_owned(),
188 self.boolean_metadata(format.has_audio),
189 ),
190 (
191 FORMAT_HAS_VIDEO.to_owned(),
192 self.boolean_metadata(format.has_video),
193 ),
194 ];
195 if let Some(value) = format.extension {
196 fields.push((FORMAT_EXTENSION.to_owned(), self.string_metadata(value)));
197 }
198 if let Some(value) = format.quality {
199 fields.push(("quality".to_owned(), self.string_metadata(value)));
200 }
201 for (key, value) in [
202 ("width", format.width),
203 ("height", format.height),
204 ("bitrate", format.bitrate),
205 ("content_length", format.content_length),
206 ] {
207 if let Some(value) = value {
208 fields.push((key.to_owned(), self.integer_metadata(value)));
209 }
210 }
211 if let Some(value) = format.codecs {
212 fields.push(("codecs".to_owned(), self.string_metadata(value)));
213 }
214 self.format(format.id, self.metadata(fields))
215 }
216}
217
218#[derive(Debug, Error)]
220pub enum YoutubeError<RequestError, ResponseError, ChallengeError> {
221 #[error("malformed Youtube URL: {0}")]
223 Url(String),
224 #[error("Youtube request failed: {0}")]
226 Request(RequestError),
227 #[error("Youtube response decoding failed: {0}")]
229 Response(ResponseError),
230 #[error("the Youtube page contains no parseable initial player response")]
232 MissingPlayerResponse,
233 #[error("the Youtube page contains no InnerTube API key")]
235 MissingApiKey,
236 #[error("Youtube reports: {0}")]
238 Unavailable(String),
239 #[error("the player response describes no audio or video format")]
241 NoDirectFormat,
242 #[error("Youtube challenge resolution failed: {0}")]
244 Challenge(ChallengeError),
245 #[error("every described format is withheld behind an unresolved {0} challenge")]
247 Withheld(WithheldReason),
248}
249
250#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
252pub enum WithheldReason {
253 Throttle,
255 Signature,
257}
258
259impl Display for WithheldReason {
260 fn fmt(&self, formatter: &mut Formatter<'_>) -> FormatResult {
261 formatter.write_str(match self {
262 Self::Signature => "signature",
263 Self::Throttle => "throttling",
264 })
265 }
266}
267
268fn withheld_reason(challenge: &YoutubeChallenge) -> WithheldReason {
270 match challenge {
271 YoutubeChallenge::Signature(_) => WithheldReason::Signature,
272 YoutubeChallenge::Throttle(_) => WithheldReason::Throttle,
273 }
274}
275
276pub const YOUTUBE_DESCRIBED: &str = "youtube_described";
278
279pub const YOUTUBE_WITHHELD: &str = "youtube_withheld";
281
282pub const YOUTUBE_THROTTLED: &str = "youtube_throttled";
284
285pub const YOUTUBE_UNREADABLE: &str = "youtube_unreadable";
287
288#[ext(name = YoutubeNotesExt)]
290pub impl<This> This
291where
292 This: MetadataViewAlg,
293{
294 fn granting_notes(&self, metadata: &This::Metadata) -> Vec<String> {
301 let counted = |key| self.metadata_number(metadata, key);
302 let mut notes = Vec::new();
303 if let Some(described) = counted(YOUTUBE_DESCRIBED) {
304 notes.push(format!("the player response described {described} formats"));
305 }
306 if let Some(unreadable) = counted(YOUTUBE_UNREADABLE).filter(|count| *count > 0) {
307 notes.push(format!(
308 "{unreadable} described formats state no location this interpreter can read"
309 ));
310 }
311 if let Some(withheld) = counted(YOUTUBE_WITHHELD) {
312 notes.push(if withheld == 0 {
313 "every signature was answered by the player program".to_owned()
314 } else {
315 format!("{withheld} formats withheld behind an unanswered signature")
316 });
317 }
318 if let Some(throttled) = counted(YOUTUBE_THROTTLED).filter(|count| *count > 0) {
319 notes.push(format!("{throttled} formats served at a throttled rate"));
320 }
321 notes
322 }
323}
324
325const SIGNED_URL_MARK: &str = " for url (";
327
328const FORBIDDEN: &str = "403 Forbidden";
330
331#[must_use]
337pub fn media_failure(detail: &str) -> String {
338 let stated = detail.find(SIGNED_URL_MARK).map_or_else(
339 || detail.to_owned(),
340 |index| format!("{}; signed media URL redacted", &detail[..index]),
341 );
342 if stated.contains(FORBIDDEN) {
343 return format!("media request rejected: HTTP 403 Forbidden\n{stated}");
344 }
345 stated
346}
347
348#[must_use]
350pub fn youtube_id(input: &str) -> Option<String> {
351 let without_scheme = input
352 .strip_prefix("https://")
353 .or_else(|| input.strip_prefix("http://"))?;
354 let (authority, suffix) = without_scheme
355 .split_once('/')
356 .map_or((without_scheme, ""), |(authority, suffix)| {
357 (authority, suffix)
358 });
359 let host = authority.split('@').next_back()?.split(':').next()?;
360 match host {
361 "youtu.be" | "www.youtu.be" => suffix
362 .split(['?', '#', '/'])
363 .next()
364 .filter(|id| !id.is_empty())
365 .map(str::to_owned),
366 "youtube.com" | "www.youtube.com" | "m.youtube.com" => {
367 if let Some(query) = suffix.strip_prefix("watch?") {
368 query.split('&').find_map(|pair| {
369 let (key, value) = pair.split_once('=')?;
370 (key == "v" && !value.is_empty()).then(|| value.to_owned())
371 })
372 } else {
373 suffix
374 .split(['?', '#'])
375 .next()?
376 .split('/')
377 .nth(1)
378 .filter(|id| !id.is_empty())
379 .map(str::to_owned)
380 }
381 }
382 _ => None,
383 }
384}