Skip to main content

rsynko_yt/
extraction.rs

1use crate::*;
2use alux_ext::ext;
3use rsynko_media::*;
4use std::fmt::{Display, Formatter, Result as FormatResult};
5use thiserror::Error;
6
7/// Derives Youtube extraction from request, response, and domain-data algebras.
8#[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    /// Extracts one Youtube watch URL into a preference-ordered media catalog.
24    ///
25    /// # Errors
26    ///
27    /// Returns URL, request, response, availability, challenge, or empty-catalog failure.
28    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        // A challenge is posed by one player program, and Youtube grants a catalog matching the
45        // program the client states it runs, so the program is retrieved before the catalog.
46        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                // A throttled representation is retrievable, only slowly, so it stays in the
80                // catalog rather than disappearing from it.
81                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    /// Observes the union of the catalogs this interpreter's clients are granted, in claim order.
128    ///
129    /// Each client is granted a different catalog, and neither is the whole truth: one states a
130    /// muxed representation carrying both streams, another states every adaptive representation on
131    /// its own. A client refused states nothing about the others.
132    ///
133    /// # Errors
134    ///
135    /// Returns request or response failure.
136    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    /// Defines one generic format from Youtube player observations and its granted URL.
183    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/// Denotes failure to interpret one Youtube watch URL.
219#[derive(Debug, Error)]
220pub enum YoutubeError<RequestError, ResponseError, ChallengeError> {
221    /// Denotes a malformed or unsupported Youtube URL.
222    #[error("malformed Youtube URL: {0}")]
223    Url(String),
224    /// Denotes request execution failure.
225    #[error("Youtube request failed: {0}")]
226    Request(RequestError),
227    /// Denotes response decoding failure.
228    #[error("Youtube response decoding failed: {0}")]
229    Response(ResponseError),
230    /// Denotes absence of a parseable player response.
231    #[error("the Youtube page contains no parseable initial player response")]
232    MissingPlayerResponse,
233    /// Denotes absence of the player API key in the watch-page configuration.
234    #[error("the Youtube page contains no InnerTube API key")]
235    MissingApiKey,
236    /// Denotes an unavailable video according to the player response.
237    #[error("Youtube reports: {0}")]
238    Unavailable(String),
239    /// Denotes absence of any described format.
240    #[error("the player response describes no audio or video format")]
241    NoDirectFormat,
242    /// Denotes failure to resolve the challenges the player response poses.
243    #[error("Youtube challenge resolution failed: {0}")]
244    Challenge(ChallengeError),
245    /// Denotes that every described format stayed guarded by an unresolved challenge.
246    #[error("every described format is withheld behind an unresolved {0} challenge")]
247    Withheld(WithheldReason),
248}
249
250/// Names the kind of unresolved challenge withholding an entire format catalog.
251#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
252pub enum WithheldReason {
253    /// Withholds because throttling parameters governing the formats stayed unresolved.
254    Throttle,
255    /// Withholds because signatures guarding the formats stayed unresolved.
256    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
268/// Names why one challenge withheld a format.
269fn withheld_reason(challenge: &YoutubeChallenge) -> WithheldReason {
270    match challenge {
271        YoutubeChallenge::Signature(_) => WithheldReason::Signature,
272        YoutubeChallenge::Throttle(_) => WithheldReason::Throttle,
273    }
274}
275
276/// Names the observation counting the formats the player response described.
277pub const YOUTUBE_DESCRIBED: &str = "youtube_described";
278
279/// Names the observation counting the formats an unresolved signature withheld.
280pub const YOUTUBE_WITHHELD: &str = "youtube_withheld";
281
282/// Names the observation counting the formats an unresolved throttling parameter governs.
283pub const YOUTUBE_THROTTLED: &str = "youtube_throttled";
284
285/// Names the observation counting the described formats the interpreter could not read.
286pub const YOUTUBE_UNREADABLE: &str = "youtube_unreadable";
287
288/// Derives what a reader is told about extraction and about the requests that follow it.
289#[ext(name = YoutubeNotesExt)]
290pub impl<This> This
291where
292    This: MetadataViewAlg,
293{
294    /// States what extraction observed about granting, in the words a reader reads.
295    ///
296    /// A player response describes formats this interpreter may or may not be able to read: some
297    /// state no location, some are held behind an unanswered signature, and some are served
298    /// slowly on purpose. What is said here is what was counted, and nothing is said about a
299    /// count the extractor did not state.
300    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
325/// Names what a media URL carries that a reader must never be shown.
326const SIGNED_URL_MARK: &str = " for url (";
327
328/// Names the rejection every reader of withheld media eventually meets.
329const FORBIDDEN: &str = "403 Forbidden";
330
331/// States what a reader is told about one failed media request.
332///
333/// A media URL is signed, which makes it a credential rather than an address: it is never
334/// repeated, however the failure is worded. What is left is stated first in words a reader knows,
335/// and the diagnostic follows for whoever goes looking.
336#[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/// Observes the video identity denoted by a supported Youtube URL.
349#[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}