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#[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 [ANDROID_CLIENT, VISIONOS_CLIENT].into_iter()
45 }
46}
47
48impl YoutubeProgramAlg for RuntimeEnvironment {
49 fn program_timestamp(&self, program: &str) -> Option<i64> {
50 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
288fn unreadable_formats(player: &Value) -> usize {
290 described_formats(player).filter(|value| direct_format(value).is_none()).count()
291}
292
293fn 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
301fn 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
347fn 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}