Skip to main content

rsynko_reqwest/
youtube.rs

1use crate::{ANDROID_CLIENT, RuntimeEnvironment, SignatureProgram, VISIONOS_CLIENT};
2use rsynko_media::*;
3use rsynko_memory::{Extraction, Format, Media, MediaSyntax, YoutubeRequest, YoutubeRequestSyntax};
4use rsynko_yt::*;
5use serde_json::Value;
6use std::convert::Infallible;
7use url::{Url, form_urlencoded};
8
9/// Denotes the challenges one player program resolved, and the values it resolved them to.
10///
11/// The throttling parameter is governed by an arbitrary player function rather than the fixed
12/// transformations a signature poses, so this runtime answers signatures and leaves a throttling
13/// parameter posed. A representation is then served slowly rather than withheld.
14#[derive(Clone, Debug, Default)]
15pub struct YoutubeSolutions(Vec<(YoutubeChallenge, String)>);
16
17impl YoutubeSorts for RuntimeEnvironment {
18    type Request = YoutubeRequest;
19    type Solutions = YoutubeSolutions;
20}
21
22impl YoutubeRequestAlg for RuntimeEnvironment {
23    fn watch_request(&self, url: impl Into<String>) -> Self::Request {
24        YoutubeRequestSyntax.watch_request(url)
25    }
26
27    fn player_request(&self, id: impl Into<String>, api_key: impl Into<String>, claim: &PlayerClaim) -> Self::Request {
28        YoutubeRequestSyntax.player_request(id, api_key, claim)
29    }
30
31    fn player_program_request(&self, url: impl Into<String>) -> Self::Request {
32        YoutubeRequestSyntax.player_program_request(url)
33    }
34
35    fn media_request(&self, url: impl Into<String>) -> Self::Request {
36        YoutubeRequestSyntax.media_request(url)
37    }
38}
39
40impl YoutubeClientAlg for RuntimeEnvironment {
41    fn player_clients(&self) -> impl Iterator<Item = &str> {
42        // The handheld client states one muxed representation, served without throttling; the
43        // headset client states every adaptive representation. Neither alone is the catalog.
44        [ANDROID_CLIENT, VISIONOS_CLIENT].into_iter()
45    }
46}
47
48impl YoutubeProgramAlg for RuntimeEnvironment {
49    fn program_timestamp(&self, program: &str) -> Option<i64> {
50        // The player states the timestamp its signatures were issued under.
51        let marker = "signatureTimestamp:";
52        let tail = program.get(program.find(marker)? + marker.len()..)?;
53        let digits: String = tail.chars().take_while(char::is_ascii_digit).collect();
54        digits.parse().ok()
55    }
56}
57
58impl FormatSourceAlg<YoutubeRequest> for RuntimeEnvironment {
59    fn format_source(&self, format: &Format) -> Option<YoutubeRequest> {
60        format.source().map(|url| self.media_request(url))
61    }
62}
63
64impl MetadataAlg for RuntimeEnvironment {
65    fn empty_metadata(&self) -> Self::Metadata {
66        MediaSyntax.empty_metadata()
67    }
68    fn null_metadata(&self) -> Self::Value {
69        MediaSyntax.null_metadata()
70    }
71    fn boolean_metadata(&self, value: bool) -> Self::Value {
72        MediaSyntax.boolean_metadata(value)
73    }
74    fn integer_metadata(&self, value: i64) -> Self::Value {
75        MediaSyntax.integer_metadata(value)
76    }
77    fn float_metadata(&self, value: f64) -> Self::Value {
78        MediaSyntax.float_metadata(value)
79    }
80    fn string_metadata(&self, value: impl Into<String>) -> Self::Value {
81        MediaSyntax.string_metadata(value)
82    }
83    fn list_metadata(&self, values: impl IntoIterator<Item = Self::Value>) -> Self::Value {
84        MediaSyntax.list_metadata(values)
85    }
86    fn record_metadata(&self, record: Self::Metadata) -> Self::Value {
87        MediaSyntax.record_metadata(record)
88    }
89    fn metadata(&self, fields: impl IntoIterator<Item = (String, Self::Value)>) -> Self::Metadata {
90        MediaSyntax.metadata(fields)
91    }
92}
93
94impl FormatAlg for RuntimeEnvironment {
95    fn format(&self, id: impl Into<String>, metadata: Self::Metadata) -> Self::Format {
96        MediaSyntax.format(id, metadata)
97    }
98}
99
100impl ExtractionAlg for RuntimeEnvironment {
101    fn media(
102        &self,
103        id: impl Into<String>,
104        metadata: Self::Metadata,
105        formats: impl IntoIterator<Item = Self::Format>,
106    ) -> Self::Extraction {
107        MediaSyntax.media(id, metadata, formats)
108    }
109
110    fn url_reference(
111        &self,
112        url: impl Into<String>,
113        extractor: Option<Self::Extractor>,
114        transparent: bool,
115    ) -> Self::Extraction {
116        MediaSyntax.url_reference(url, extractor, transparent)
117    }
118
119    fn extraction_collection(
120        &self,
121        id: Option<String>,
122        kind: CollectionKind,
123        metadata: Self::Metadata,
124        entries: impl IntoIterator<Item = Self::Extraction>,
125    ) -> Self::Extraction {
126        MediaSyntax.extraction_collection(id, kind, metadata, entries)
127    }
128}
129
130impl ExtractionViewAlg for RuntimeEnvironment {
131    fn as_media(&self, extraction: Extraction) -> Option<Media> {
132        MediaSyntax.as_media(extraction)
133    }
134}
135
136impl MediaViewAlg for RuntimeEnvironment {
137    fn media_id<'a>(&self, media: &'a Media) -> &'a str {
138        MediaSyntax.media_id(media)
139    }
140
141    fn media_title<'a>(&self, media: &'a Media) -> Option<&'a str> {
142        MediaSyntax.media_title(media)
143    }
144
145    fn media_formats<'a>(&self, media: &'a Media) -> &'a [Format] {
146        MediaSyntax.media_formats(media)
147    }
148}
149
150impl YoutubeResponseAlg for RuntimeEnvironment {
151    type Error = serde_json::Error;
152
153    fn decode_youtube_watch(&self, bytes: &[u8]) -> Result<YoutubeWatchPage, Self::Error> {
154        let page = String::from_utf8_lossy(bytes);
155        let player = player_response(&page);
156        Ok(YoutubeWatchPage {
157            has_player_response: player.is_some(),
158            api_key: quoted_config(&page, "INNERTUBE_API_KEY").map(str::to_owned),
159            player_url: player_program_url(&page),
160            visitor_data: quoted_config(&page, "VISITOR_DATA").map(str::to_owned),
161            title: player
162                .as_ref()
163                .and_then(|value| value.pointer("/videoDetails/title"))
164                .and_then(Value::as_str)
165                .map(str::to_owned),
166        })
167    }
168
169    fn decode_youtube_player(&self, bytes: &[u8]) -> Result<YoutubePlayer, Self::Error> {
170        let player: Value = serde_json::from_slice(bytes)?;
171        Ok(YoutubePlayer {
172            status: player.pointer("/playabilityStatus/status").and_then(Value::as_str).map(str::to_owned),
173            reason: player.pointer("/playabilityStatus/reason").and_then(Value::as_str).map(str::to_owned),
174            title: player.pointer("/videoDetails/title").and_then(Value::as_str).map(str::to_owned),
175            formats: direct_formats(&player),
176            unreadable: unreadable_formats(&player),
177        })
178    }
179}
180
181fn player_response(page: &str) -> Option<Value> {
182    ["ytInitialPlayerResponse =", "ytInitialPlayerResponse="].into_iter().find_map(|marker| {
183        let tail = page.get(page.find(marker)? + marker.len()..)?;
184        serde_json::from_str(balanced_object(tail)?).ok()
185    })
186}
187
188fn quoted_config<'a>(page: &'a str, key: &str) -> Option<&'a str> {
189    let marker = format!("\"{key}\":\"");
190    let tail = page.get(page.find(&marker)? + marker.len()..)?;
191    tail.get(..tail.find('"')?)
192}
193
194fn balanced_object(input: &str) -> Option<&str> {
195    let start = input.find('{')?;
196    let mut depth = 0_u32;
197    let mut in_string = false;
198    let mut escaped = false;
199    for (offset, character) in input[start..].char_indices() {
200        if in_string {
201            if escaped {
202                escaped = false;
203            } else if character == '\\' {
204                escaped = true;
205            } else if character == '"' {
206                in_string = false;
207            }
208            continue;
209        }
210        match character {
211            '"' => in_string = true,
212            '{' => depth += 1,
213            '}' => {
214                depth -= 1;
215                if depth == 0 {
216                    return input.get(start..=start + offset);
217                }
218            }
219            _ => {}
220        }
221    }
222    None
223}
224
225impl YoutubeUrlAlg for RuntimeEnvironment {
226    fn throttle_challenge(&self, url: &str) -> Option<String> {
227        Url::parse(url).ok()?.query_pairs().find(|(key, _)| key == "n").map(|(_, value)| value.into_owned())
228    }
229
230    fn with_throttle(&self, url: &str, solution: &str) -> String {
231        replaced_query(url, "n", solution)
232    }
233
234    fn with_signature(&self, url: &str, parameter: &str, signature: &str) -> String {
235        replaced_query(url, parameter, signature)
236    }
237}
238
239impl YoutubeChallengeAlg for RuntimeEnvironment {
240    type Error = Infallible;
241
242    fn solve_challenges(
243        &self,
244        program: &str,
245        challenges: impl IntoIterator<Item = YoutubeChallenge>,
246    ) -> Result<Self::Solutions, Self::Error> {
247        let Ok(decipher) = SignatureProgram::recover(program) else {
248            return Ok(YoutubeSolutions::default());
249        };
250        Ok(YoutubeSolutions(
251            challenges
252                .into_iter()
253                .filter_map(|challenge| match &challenge {
254                    YoutubeChallenge::Signature(value) => {
255                        let solved = decipher.decipher(value);
256                        Some((challenge, solved))
257                    }
258                    YoutubeChallenge::Throttle(_) => None,
259                })
260                .collect(),
261        ))
262    }
263}
264
265impl YoutubeSolutionAlg for RuntimeEnvironment {
266    fn solution_of(&self, solutions: &Self::Solutions, challenge: &YoutubeChallenge) -> Option<String> {
267        solutions.0.iter().find(|(posed, _)| posed == challenge).map(|(_, solution)| solution.clone())
268    }
269}
270
271fn replaced_query(url: &str, key: &str, value: &str) -> String {
272    let Ok(mut parsed) = Url::parse(url) else {
273        return url.to_owned();
274    };
275    let kept: Vec<(String, String)> = parsed
276        .query_pairs()
277        .filter(|(name, _)| name != key)
278        .map(|(name, value)| (name.into_owned(), value.into_owned()))
279        .collect();
280    parsed.query_pairs_mut().clear().extend_pairs(kept).append_pair(key, value);
281    parsed.into()
282}
283
284fn direct_formats(player: &Value) -> Vec<YoutubeFormat> {
285    described_formats(player).filter_map(direct_format).collect()
286}
287
288/// Counts the described formats this interpreter could not read.
289fn unreadable_formats(player: &Value) -> usize {
290    described_formats(player).filter(|value| direct_format(value).is_none()).count()
291}
292
293/// Observes every format the response described, muxed and adaptive alike.
294fn described_formats(player: &Value) -> impl Iterator<Item = &Value> {
295    ["/streamingData/formats", "/streamingData/adaptiveFormats"]
296        .into_iter()
297        .filter_map(|path| player.pointer(path).and_then(Value::as_array))
298        .flatten()
299}
300
301/// Recovers the location a format states, whether stated directly or behind a signature.
302fn format_source(value: &Value) -> Option<YoutubeFormatSource> {
303    if let Some(url) = value.get("url").and_then(Value::as_str) {
304        return Some(YoutubeFormatSource::Direct(url.to_owned()));
305    }
306    let cipher = value.get("signatureCipher").and_then(Value::as_str)?;
307    let (mut url, mut signature, mut parameter) = (None, None, None);
308    for (key, field) in form_urlencoded::parse(cipher.as_bytes()) {
309        match key.as_ref() {
310            "url" => url = Some(field.into_owned()),
311            "s" => signature = Some(field.into_owned()),
312            "sp" => parameter = Some(field.into_owned()),
313            _ => {}
314        }
315    }
316    Some(YoutubeFormatSource::Signed {
317        url: url?,
318        signature: signature?,
319        parameter: parameter.unwrap_or_else(|| DEFAULT_SIGNATURE_PARAMETER.to_owned()),
320    })
321}
322
323fn direct_format(value: &Value) -> Option<YoutubeFormat> {
324    let source = format_source(value)?;
325    let mime = value.get("mimeType")?.as_str()?;
326    let has_video = mime.starts_with("video/");
327    let has_audio =
328        mime.starts_with("audio/") || value.get("audioQuality").is_some() || value.get("audioSampleRate").is_some();
329    if !has_audio && !has_video {
330        return None;
331    }
332    Some(YoutubeFormat {
333        id: value.get("itag")?.as_u64()?.to_string(),
334        source,
335        extension: mime.split('/').nth(1)?.split(';').next().map(str::to_owned),
336        has_audio,
337        has_video,
338        quality: value.get("qualityLabel").or_else(|| value.get("quality")).and_then(Value::as_str).map(str::to_owned),
339        width: value.get("width").and_then(Value::as_i64),
340        height: value.get("height").and_then(Value::as_i64),
341        bitrate: value.get("bitrate").and_then(Value::as_i64),
342        content_length: value.get("contentLength").and_then(Value::as_str).and_then(|length| length.parse().ok()),
343        codecs: mime.split("codecs=\"").nth(1).and_then(|tail| tail.split('"').next()).map(str::to_owned),
344    })
345}
346
347/// Locates the player program the watch page states, as an absolute URL.
348fn player_program_url(page: &str) -> Option<String> {
349    let stated = ["\"jsUrl\":\"", "\"PLAYER_JS_URL\":\""]
350        .into_iter()
351        .find_map(|marker| {
352            let tail = page.get(page.find(marker)? + marker.len()..)?;
353            tail.get(..tail.find('"')?)
354        })?
355        .replace("\\/", "/");
356    Some(if stated.starts_with('/') { format!("https://www.youtube.com{stated}") } else { stated })
357}