1use crate::*;
4use alux_ext::ext;
5use anyhow::{Result, bail};
6use rsynko_download::*;
7use rsynko_media::*;
8use std::fmt::{Debug, Display};
9use std::path::PathBuf;
10
11pub trait YoutubeChallengeLawFixture {
13 fn solve_law_challenge(&mut self, challenge: YoutubeChallenge, solution: &str);
15 fn law_challenge_applications(&self) -> Vec<Vec<YoutubeChallenge>>;
17}
18
19pub trait YoutubeLawFixture: YoutubeSorts {
21 fn law_watch_url(&self) -> String;
23 fn law_video_id(&self) -> String;
25 fn law_watch_bytes(&self) -> Vec<u8>;
27 fn law_player_bytes(&self) -> Vec<u8>;
29 fn law_executed_requests(&self) -> Vec<Self::Request>;
31 fn law_retrieved_requests(&self) -> Vec<Self::Request>;
33}
34
35#[ext(name = YoutubeUrlLaws)]
37pub impl<This> This
38where
39 This: YoutubeUrlAlg,
40{
41 fn youtube_url_laws(&self) -> Result<()> {
52 let url = "https://media.example/video.mp4?n=posed";
53 let parameter = DEFAULT_SIGNATURE_PARAMETER;
54 let answered = self.with_throttle(url, "answered");
55 if self.throttle_challenge(&answered).as_deref() != Some("answered") {
56 bail!("a solved throttling parameter is not the one the answered URL poses");
57 }
58 let signed = self.with_signature(url, parameter, "solved");
59 if self.throttle_challenge(&signed) != self.throttle_challenge(url) {
60 bail!("attaching a signature disturbed the throttling challenge");
61 }
62 Ok(())
63 }
64}
65
66#[ext(name = YoutubeChallengeLaws)]
68pub impl<This, ChallengeError> This
69where
70 This: YoutubeChallengeAlg<Error = ChallengeError> + YoutubeSolutionAlg + YoutubeUrlAlg,
71{
72 fn youtube_granting_laws(&self) -> Result<()>
85 where
86 ChallengeError: Debug,
87 {
88 let formats = [
89 law_format(
90 "18",
91 YoutubeFormatSource::Direct("https://media.example/a.mp4".to_owned()),
92 ),
93 law_format(
94 "137",
95 YoutubeFormatSource::Signed {
96 url: "https://media.example/b.mp4".to_owned(),
97 signature: "obfuscated".to_owned(),
98 parameter: DEFAULT_SIGNATURE_PARAMETER.to_owned(),
99 },
100 ),
101 law_format(
102 "22",
103 YoutubeFormatSource::Direct("https://media.example/c.mp4?n=posed".to_owned()),
104 ),
105 ];
106 let formats = &formats;
107 let grants = match self.grant_formats(law_program(), formats) {
108 Ok(grants) => grants.into_iter().collect::<Vec<_>>(),
109 Err(error) => bail!("challenge resolution failed: {error:?}"),
110 };
111 if grants.len() != formats.len() {
112 bail!(
113 "granting yielded {} outcomes for {} formats",
114 grants.len(),
115 formats.len()
116 );
117 }
118 for (format, grant) in formats.iter().zip(&grants) {
119 if grant.id() != format.id {
120 bail!(
121 "granting did not preserve declaration order at {}",
122 grant.id()
123 );
124 }
125 let guarded = format.source.signature_challenge().is_some();
126 let throttled = self.throttle_challenge(format.source.url()).is_some();
127 match grant {
128 YoutubeGrant::Granted { url, .. } => {
129 if guarded || throttled {
130 bail!("a guarded source was granted outright at {url}");
131 }
132 if url != format.source.url() {
133 bail!("an unguarded source was not granted its stated URL unchanged");
134 }
135 }
136 YoutubeGrant::Throttled { challenge, .. } => {
137 if !throttled {
138 bail!(
139 "a source posing no throttling parameter was throttled: {challenge:?}"
140 );
141 }
142 if guarded {
143 bail!("a signature guarding the representation was answered by throttling");
144 }
145 }
146 YoutubeGrant::Withheld { challenge, .. } => {
147 if !guarded {
148 bail!("an unguarded source was withheld behind {challenge:?}");
149 }
150 }
151 }
152 if grant.retrievable().is_none() && !guarded {
153 bail!(
154 "an unresolved throttling parameter denied retrieval of {}",
155 grant.id()
156 );
157 }
158 }
159 Ok(())
160 }
161}
162
163#[ext(name = YoutubeSolutionLaws)]
165pub impl<This, ChallengeError> This
166where
167 This: YoutubeChallengeAlg<Error = ChallengeError>
168 + YoutubeSolutionAlg
169 + YoutubeUrlAlg
170 + YoutubeChallengeLawFixture,
171{
172 fn youtube_solution_laws(&mut self) -> Result<()>
186 where
187 ChallengeError: Debug,
188 {
189 let signed_url = "https://media.example/signed.mp4";
190 let throttled_url = "https://media.example/throttled.mp4?n=posed";
191 self.solve_law_challenge(YoutubeChallenge::Signature("guarded".to_owned()), "solved");
192 self.solve_law_challenge(YoutubeChallenge::Throttle("posed".to_owned()), "answered");
193
194 let formats = [
195 law_format(
196 "137",
197 YoutubeFormatSource::Signed {
198 url: signed_url.to_owned(),
199 signature: "guarded".to_owned(),
200 parameter: DEFAULT_SIGNATURE_PARAMETER.to_owned(),
201 },
202 ),
203 law_format("18", YoutubeFormatSource::Direct(throttled_url.to_owned())),
204 law_format("22", YoutubeFormatSource::Direct(throttled_url.to_owned())),
205 ];
206 let before = self.law_challenge_applications().len();
207 let grants = match self.grant_formats(law_program(), &formats) {
208 Ok(grants) => grants,
209 Err(error) => bail!("challenge resolution failed: {error:?}"),
210 };
211
212 match &grants[0] {
213 YoutubeGrant::Granted { url, .. }
214 if *url
215 == self.with_signature(signed_url, DEFAULT_SIGNATURE_PARAMETER, "solved") => {}
216 other => bail!("a solved signature was not attached under its parameter: {other:?}"),
217 }
218 for grant in &grants[1..] {
219 match grant {
220 YoutubeGrant::Granted { url, .. }
221 if self.throttle_challenge(url).as_deref() == Some("answered") => {}
222 other => {
223 bail!("a solved throttling parameter did not replace the posed one: {other:?}")
224 }
225 }
226 }
227
228 let applications = self.law_challenge_applications();
229 if applications.len() != before + 1 {
230 bail!(
231 "granting resolved in {} applications rather than one",
232 applications.len() - before
233 );
234 }
235 let posed = &applications[before];
236 if posed
237 .iter()
238 .filter(|challenge| **challenge == YoutubeChallenge::Throttle("posed".to_owned()))
239 .count()
240 != 1
241 {
242 bail!("a challenge two formats share was not posed exactly once: {posed:?}");
243 }
244 Ok(())
245 }
246}
247
248#[ext(name = YoutubeRequestLaws)]
250pub impl<This, Request> This
251where
252 This: YoutubeSorts<Request = Request> + YoutubeRequestAlg,
253 Request: PartialEq + Debug,
254{
255 fn youtube_request_laws(&self) -> Result<()> {
267 let url = "https://www.youtube.com/watch?v=abc123";
268 if self.watch_request(url) != self.watch_request(url) {
269 bail!("a watch request is not a function of its URL");
270 }
271 if self.watch_request(url) == self.media_request(url) {
272 bail!("retrieving a watch page denotes the same request as retrieving media");
273 }
274 let claim = PlayerClaim {
275 client: "CLIENT".to_owned(),
276 visitor: Some("session".to_owned()),
277 timestamp: Some(19_000),
278 };
279 let player = self.player_request("abc123", "key", &claim);
280 if player != self.player_request("abc123", "key", &claim) {
281 bail!("a player request is not a function of the values naming it");
282 }
283 if player == self.player_request("abc123", "other", &claim) {
284 bail!("a player request does not depend on its API key");
285 }
286 if player == self.player_request("other", "key", &claim) {
287 bail!("a player request does not depend on its video identity");
288 }
289 if player == self.player_request("abc123", "key", &PlayerClaim::default()) {
290 bail!("a player request does not depend on what the client claims about itself");
291 }
292 let other = PlayerClaim {
293 client: "OTHER".to_owned(),
294 ..claim.clone()
295 };
296 if player == self.player_request("abc123", "key", &other) {
297 bail!("a player request does not depend on which client makes the claim");
298 }
299 Ok(())
300 }
301}
302
303#[ext(name = YoutubeResponseLaws)]
305pub impl<This> This
306where
307 This: YoutubeResponseAlg<Error: Debug> + YoutubeLawFixture,
308{
309 fn youtube_response_laws(&self) -> Result<()> {
321 let bytes = self.law_watch_bytes();
322 let page = match self.decode_youtube_watch(&bytes) {
323 Ok(page) => page,
324 Err(error) => bail!("decoding the watch page failed: {error:?}"),
325 };
326 if !page.has_player_response {
327 bail!("a watch page describing a playable video observes no player response");
328 }
329 if page.api_key.is_none() {
330 bail!("a watch page describing a playable video observes no API key");
331 }
332 match self.decode_youtube_watch(&bytes) {
333 Ok(again) if again == page => {}
334 _ => bail!("decoding a watch page is not a function of its bytes"),
335 }
336
337 let bytes = self.law_player_bytes();
338 let player = match self.decode_youtube_player(&bytes) {
339 Ok(player) => player,
340 Err(error) => bail!("decoding the player response failed: {error:?}"),
341 };
342 if player.status.as_deref() != Some("OK") {
343 bail!("a player response describing a playable video is not observed as playable");
344 }
345 if player.formats.is_empty() {
346 bail!("a player response describing one format observes none");
347 }
348 for format in &player.formats {
349 if format.source.url().is_empty() {
350 bail!("a described format states no location for its representation");
351 }
352 }
353 match self.decode_youtube_player(&bytes) {
354 Ok(again) if again == player => {}
355 _ => bail!("decoding a player response is not a function of its bytes"),
356 }
357 Ok(())
358 }
359}
360
361#[ext(name = YoutubeExtractionLaws)]
363pub impl<This, Request> This
364where
365 This: YoutubeSorts<Request = Request>
366 + YoutubeRequestAlg
367 + YoutubeClientAlg
368 + YoutubeProgramAlg
369 + YoutubeRequestBytesAlg<Error: Display>
370 + YoutubeResponseAlg<Error: Display>
371 + YoutubeChallengeAlg<Error: Display>
372 + YoutubeSolutionAlg
373 + YoutubeUrlAlg
374 + MetadataAlg
375 + FormatAlg
376 + ExtractionAlg
377 + ExtractionViewAlg
378 + MediaViewAlg
379 + YoutubeLawFixture,
380 Request: PartialEq + Debug,
381{
382 fn youtube_extraction_laws(&self) -> Result<()> {
395 let watch_url = self.law_watch_url();
396 let id = self.law_video_id();
397 if youtube_id(&watch_url).as_deref() != Some(id.as_str()) {
398 bail!("the watch URL does not denote the identity the fixture states");
399 }
400 if youtube_id(&format!("https://youtu.be/{id}")).as_deref() != Some(id.as_str()) {
401 bail!("the short spelling of one video denotes another identity");
402 }
403 if let Some(other) = youtube_id("https://example.com/watch?v=abc123") {
404 bail!("an unsupported host denoted the video identity {other}");
405 }
406
407 let before = self.law_executed_requests();
408 match self.extract_youtube("https://example.com/watch?v=abc123") {
409 Err(YoutubeError::Url(_)) => {}
410 Ok(_) => bail!("an unsupported URL extracted"),
411 Err(error) => bail!("an unsupported URL failed as {error}"),
412 }
413 if self.law_executed_requests() != before {
414 bail!("an unsupported URL executed a request");
415 }
416
417 let extraction = match self.extract_youtube(&watch_url) {
418 Ok(extraction) => extraction,
419 Err(error) => bail!("extracting the fixture URL failed: {error}"),
420 };
421 let executed = self.law_executed_requests();
422 let performed = &executed[before.len()..];
423 if performed.len() < 2 {
424 bail!(
425 "extraction executed {} requests rather than several",
426 performed.len()
427 );
428 }
429 if performed[0] != self.watch_request(&watch_url) {
430 bail!(
431 "extraction did not begin with the watch request: {:?}",
432 performed[0]
433 );
434 }
435 if performed[1..].contains(&performed[0]) {
436 bail!("extraction executed the watch request more than once");
437 }
438
439 let Some(media) = self.as_media(extraction) else {
440 bail!("extracting one video did not denote one media item");
441 };
442 if self.media_id(&media) != id {
443 bail!(
444 "the extracted media carries {} rather than {id}",
445 self.media_id(&media)
446 );
447 }
448 if self.media_formats(&media).is_empty() {
449 bail!("the extracted media carries no format");
450 }
451 Ok(())
452 }
453}
454
455#[ext(name = YoutubeApplicationLaws)]
457pub impl<This, Request, Event, Progress> This
458where
459 This: YoutubeSorts<Request = Request>
460 + YoutubeRequestAlg
461 + YoutubeClientAlg
462 + YoutubeProgramAlg
463 + YoutubeRequestBytesAlg<Error: Display>
464 + YoutubeResponseAlg<Error: Display>
465 + YoutubeChallengeAlg<Error: Display>
466 + YoutubeSolutionAlg
467 + YoutubeUrlAlg
468 + MetadataAlg
469 + FormatAlg
470 + FormatViewAlg
471 + FormatPredicateAlg
472 + FormatSelectionAlg
473 + ExtractionAlg
474 + ExtractionViewAlg
475 + MediaViewAlg
476 + FormatSelectionApplyAlg
477 + FormatSourceAlg<Request>
478 + FetchStreamAlg<Request, Error: Display>
479 + AtomicPublishAlg<Error: Display>
480 + DownloadObservationAlg<Event = Event, Progress = Progress>
481 + DownloadProgressAlg<Progress = Progress>
482 + DownloadReportAlg<Event = Event>
483 + YoutubeLawFixture
484 + MediaProgramLawFixture,
485 Request: PartialEq + Debug,
486{
487 fn youtube_application_laws(&mut self) -> Result<()> {
499 let watch_url = self.law_watch_url();
500 let selection = self.best_format(self.any_format());
501 let published = match self.download_youtube(&watch_url, &selection, &OutputTarget::MediaId)
502 {
503 Ok(published) => published,
504 Err(error) => bail!("the Youtube download failed: {error}"),
505 };
506 let expected = PathBuf::from(format!("{}.{}", self.law_video_id(), self.law_extension()));
507 if published != expected {
508 bail!(
509 "naming derived {} rather than {}",
510 published.display(),
511 expected.display()
512 );
513 }
514 let retrieved = self.law_retrieved_requests();
515 if retrieved.len() != 1 {
516 bail!(
517 "retrieval opened {} requests rather than one",
518 retrieved.len()
519 );
520 }
521 if retrieved[0] == self.watch_request(&watch_url) {
522 bail!("retrieval reused the watch request rather than the media request");
523 }
524 if self.law_published_bytes(&published).as_deref() != Some(&self.expected_law_bytes()[..]) {
525 bail!("the granted representation did not reach generic download unchanged");
526 }
527 Ok(())
528 }
529}
530
531#[must_use]
533pub fn withheld_challenge(grant: &YoutubeGrant) -> Option<&YoutubeChallenge> {
534 match grant {
535 YoutubeGrant::Withheld { challenge, .. } => Some(challenge),
536 YoutubeGrant::Granted { .. } | YoutubeGrant::Throttled { .. } => None,
537 }
538}
539
540fn law_program() -> &'static str {
542 "law://player-program"
543}
544
545fn law_format(id: &str, source: YoutubeFormatSource) -> YoutubeFormat {
547 YoutubeFormat {
548 id: id.to_owned(),
549 source,
550 extension: Some("mp4".to_owned()),
551 has_audio: true,
552 has_video: true,
553 quality: None,
554 width: None,
555 height: None,
556 bitrate: None,
557 content_length: None,
558 codecs: None,
559 }
560}