Skip to main content

rsynko_yt/
laws.rs

1//! Reusable law bundles for Youtube challenge resolution, stated over the capabilities.
2
3use 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
11/// Supplies the resolved challenges and the application trace a granting scenario cannot author.
12pub trait YoutubeChallengeLawFixture {
13    /// Resolves one challenge to the value the player program would produce for it.
14    fn solve_law_challenge(&mut self, challenge: YoutubeChallenge, solution: &str);
15    /// Observes the bulk applications the interpreter received, in application order.
16    fn law_challenge_applications(&self) -> Vec<Vec<YoutubeChallenge>>;
17}
18
19/// Supplies the transport bytes and request trace a Youtube scenario cannot author for itself.
20pub trait YoutubeLawFixture: YoutubeSorts {
21    /// Names a watch URL the interpreter answers.
22    fn law_watch_url(&self) -> String;
23    /// States the video identity that URL denotes.
24    fn law_video_id(&self) -> String;
25    /// States watch-page bytes describing a playable video.
26    fn law_watch_bytes(&self) -> Vec<u8>;
27    /// States player bytes describing one retrievable format.
28    fn law_player_bytes(&self) -> Vec<u8>;
29    /// Observes the requests executed so far, in execution order.
30    fn law_executed_requests(&self) -> Vec<Self::Request>;
31    /// Observes the requests retrieval opened, in retrieval order.
32    fn law_retrieved_requests(&self) -> Vec<Self::Request>;
33}
34
35/// Checks the query-surface laws against any interpreter of Youtube URL mechanics.
36#[ext(name = YoutubeUrlLaws)]
37pub impl<This> This
38where
39    This: YoutubeUrlAlg,
40{
41    /// Checks that the two substitutions are independent and that answering is observable.
42    ///
43    /// The laws checked are:
44    ///
45    /// 1. a solved throttling parameter is the one the answered URL then poses;
46    /// 2. attaching a solved signature leaves the throttling challenge unchanged.
47    ///
48    /// # Errors
49    ///
50    /// Returns the first violated law.
51    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/// Checks the granting laws against any interpreter that resolves Youtube challenges.
67#[ext(name = YoutubeChallengeLaws)]
68pub impl<This, ChallengeError> This
69where
70    This: YoutubeChallengeAlg<Error = ChallengeError> + YoutubeSolutionAlg + YoutubeUrlAlg,
71{
72    /// Checks that granting is total, ordered, and withholds only what stays unresolved.
73    ///
74    /// The laws checked are:
75    ///
76    /// 1. granting yields exactly one outcome per described format, in declaration order;
77    /// 2. a source posing no challenge is granted its stated URL unchanged;
78    /// 3. an unresolved signature withholds exactly the formats it guards, and no others;
79    /// 4. an unresolved throttling parameter governs the rate of a still retrievable URL.
80    ///
81    /// # Errors
82    ///
83    /// Returns the first violated law, or the interpreter's resolution failure.
84    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/// Checks the granting laws that only a seeded, tracing interpreter can exercise.
164#[ext(name = YoutubeSolutionLaws)]
165pub impl<This, ChallengeError> This
166where
167    This: YoutubeChallengeAlg<Error = ChallengeError>
168        + YoutubeSolutionAlg
169        + YoutubeUrlAlg
170        + YoutubeChallengeLawFixture,
171{
172    /// Checks that a resolved challenge answers exactly its own guard, once per granting.
173    ///
174    /// The laws checked are:
175    ///
176    /// 1. a solved signature is attached under the parameter the format names;
177    /// 2. a solved throttling parameter replaces the one the URL poses;
178    /// 3. one challenge shared by several formats is posed once for every format posing it;
179    /// 4. granting resolves in exactly one bulk application;
180    /// 5. a solution is stated relative to the program posing the challenge.
181    ///
182    /// # Errors
183    ///
184    /// Returns the first violated law, or the interpreter's resolution failure.
185    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/// Checks that the request vocabulary distinguishes the retrievals it names.
249#[ext(name = YoutubeRequestLaws)]
250pub impl<This, Request> This
251where
252    This: YoutubeSorts<Request = Request> + YoutubeRequestAlg,
253    Request: PartialEq + Debug,
254{
255    /// Checks that each request is a function of its arguments and distinct from the others.
256    ///
257    /// The laws checked are:
258    ///
259    /// 1. a request is a function of the values naming it;
260    /// 2. retrieving a watch page, a player catalog, and media are three distinct requests;
261    /// 3. the player request depends on the identity, the key, and what the client claims.
262    ///
263    /// # Errors
264    ///
265    /// Returns the first violated law.
266    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/// Checks that decoding recovers the observations the transport bytes describe.
304#[ext(name = YoutubeResponseLaws)]
305pub impl<This> This
306where
307    This: YoutubeResponseAlg<Error: Debug> + YoutubeLawFixture,
308{
309    /// Checks that decoding is a function of the bytes and observes what they describe.
310    ///
311    /// The laws checked are:
312    ///
313    /// 1. decoding is a function of the bytes it reads;
314    /// 2. a watch page describing a playable video observes a player response and its API key;
315    /// 3. a player response describing one retrievable format observes it as locatable.
316    ///
317    /// # Errors
318    ///
319    /// Returns the first violated law, or the interpreter's decoding failure.
320    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/// Checks that Youtube extraction denotes exactly the video its URL identifies.
362#[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    /// Checks that identity, request order, and extracted meaning follow the watch URL.
383    ///
384    /// The laws checked are:
385    ///
386    /// 1. the supported spellings of one video denote the same identity, and no other host does;
387    /// 2. an unsupported URL fails before any request is executed;
388    /// 3. extraction executes the watch request first, then the requests that page enables;
389    /// 4. extracting one video denotes one media item carrying that identity and its formats.
390    ///
391    /// # Errors
392    ///
393    /// Returns the first violated law.
394    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/// Checks that Youtube specialization hands its media request to generic download unchanged.
456#[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    /// Checks that one Youtube URL publishes exactly the bytes its granted representation denotes.
488    ///
489    /// The laws checked are:
490    ///
491    /// 1. the published path is named from the extracted identity and the selected format;
492    /// 2. retrieval opens exactly one request, and it is neither the watch nor the player request;
493    /// 3. the granted representation reaches generic download unchanged.
494    ///
495    /// # Errors
496    ///
497    /// Returns the first violated law.
498    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/// Names the challenge a grant withheld, when it withheld one.
532#[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
540/// States the player program a granting scenario poses its challenges under.
541fn law_program() -> &'static str {
542    "law://player-program"
543}
544
545/// Describes one format a granting scenario authors.
546fn 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}