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("18", YoutubeFormatSource::Direct("https://media.example/a.mp4".to_owned())),
90            law_format(
91                "137",
92                YoutubeFormatSource::Signed {
93                    url: "https://media.example/b.mp4".to_owned(),
94                    signature: "obfuscated".to_owned(),
95                    parameter: DEFAULT_SIGNATURE_PARAMETER.to_owned(),
96                },
97            ),
98            law_format("22", YoutubeFormatSource::Direct("https://media.example/c.mp4?n=posed".to_owned())),
99        ];
100        let formats = &formats;
101        let grants = match self.grant_formats(law_program(), formats) {
102            Ok(grants) => grants.into_iter().collect::<Vec<_>>(),
103            Err(error) => bail!("challenge resolution failed: {error:?}"),
104        };
105        if grants.len() != formats.len() {
106            bail!("granting yielded {} outcomes for {} formats", grants.len(), formats.len());
107        }
108        for (format, grant) in formats.iter().zip(&grants) {
109            if grant.id() != format.id {
110                bail!("granting did not preserve declaration order at {}", grant.id());
111            }
112            let guarded = format.source.signature_challenge().is_some();
113            let throttled = self.throttle_challenge(format.source.url()).is_some();
114            match grant {
115                YoutubeGrant::Granted { url, .. } => {
116                    if guarded || throttled {
117                        bail!("a guarded source was granted outright at {url}");
118                    }
119                    if url != format.source.url() {
120                        bail!("an unguarded source was not granted its stated URL unchanged");
121                    }
122                }
123                YoutubeGrant::Throttled { challenge, .. } => {
124                    if !throttled {
125                        bail!("a source posing no throttling parameter was throttled: {challenge:?}");
126                    }
127                    if guarded {
128                        bail!("a signature guarding the representation was answered by throttling");
129                    }
130                }
131                YoutubeGrant::Withheld { challenge, .. } => {
132                    if !guarded {
133                        bail!("an unguarded source was withheld behind {challenge:?}");
134                    }
135                }
136            }
137            if grant.retrievable().is_none() && !guarded {
138                bail!("an unresolved throttling parameter denied retrieval of {}", grant.id());
139            }
140        }
141        Ok(())
142    }
143}
144
145/// Checks the granting laws that only a seeded, tracing interpreter can exercise.
146#[ext(name = YoutubeSolutionLaws)]
147pub impl<This, ChallengeError> This
148where
149    This: YoutubeChallengeAlg<Error = ChallengeError> + YoutubeSolutionAlg + YoutubeUrlAlg + YoutubeChallengeLawFixture,
150{
151    /// Checks that a resolved challenge answers exactly its own guard, once per granting.
152    ///
153    /// The laws checked are:
154    ///
155    /// 1. a solved signature is attached under the parameter the format names;
156    /// 2. a solved throttling parameter replaces the one the URL poses;
157    /// 3. one challenge shared by several formats is posed once for every format posing it;
158    /// 4. granting resolves in exactly one bulk application;
159    /// 5. a solution is stated relative to the program posing the challenge.
160    ///
161    /// # Errors
162    ///
163    /// Returns the first violated law, or the interpreter's resolution failure.
164    fn youtube_solution_laws(&mut self) -> Result<()>
165    where
166        ChallengeError: Debug,
167    {
168        let signed_url = "https://media.example/signed.mp4";
169        let throttled_url = "https://media.example/throttled.mp4?n=posed";
170        self.solve_law_challenge(YoutubeChallenge::Signature("guarded".to_owned()), "solved");
171        self.solve_law_challenge(YoutubeChallenge::Throttle("posed".to_owned()), "answered");
172
173        let formats = [
174            law_format(
175                "137",
176                YoutubeFormatSource::Signed {
177                    url: signed_url.to_owned(),
178                    signature: "guarded".to_owned(),
179                    parameter: DEFAULT_SIGNATURE_PARAMETER.to_owned(),
180                },
181            ),
182            law_format("18", YoutubeFormatSource::Direct(throttled_url.to_owned())),
183            law_format("22", YoutubeFormatSource::Direct(throttled_url.to_owned())),
184        ];
185        let before = self.law_challenge_applications().len();
186        let grants = match self.grant_formats(law_program(), &formats) {
187            Ok(grants) => grants,
188            Err(error) => bail!("challenge resolution failed: {error:?}"),
189        };
190
191        match &grants[0] {
192            YoutubeGrant::Granted { url, .. }
193                if *url == self.with_signature(signed_url, DEFAULT_SIGNATURE_PARAMETER, "solved") => {}
194            other => bail!("a solved signature was not attached under its parameter: {other:?}"),
195        }
196        for grant in &grants[1..] {
197            match grant {
198                YoutubeGrant::Granted { url, .. } if self.throttle_challenge(url).as_deref() == Some("answered") => {}
199                other => {
200                    bail!("a solved throttling parameter did not replace the posed one: {other:?}")
201                }
202            }
203        }
204
205        let applications = self.law_challenge_applications();
206        if applications.len() != before + 1 {
207            bail!("granting resolved in {} applications rather than one", applications.len() - before);
208        }
209        let posed = &applications[before];
210        if posed.iter().filter(|challenge| **challenge == YoutubeChallenge::Throttle("posed".to_owned())).count() != 1 {
211            bail!("a challenge two formats share was not posed exactly once: {posed:?}");
212        }
213        Ok(())
214    }
215}
216
217/// Checks that the request vocabulary distinguishes the retrievals it names.
218#[ext(name = YoutubeRequestLaws)]
219pub impl<This, Request> This
220where
221    This: YoutubeSorts<Request = Request> + YoutubeRequestAlg,
222    Request: PartialEq + Debug,
223{
224    /// Checks that each request is a function of its arguments and distinct from the others.
225    ///
226    /// The laws checked are:
227    ///
228    /// 1. a request is a function of the values naming it;
229    /// 2. retrieving a watch page, a player catalog, and media are three distinct requests;
230    /// 3. the player request depends on the identity, the key, and what the client claims.
231    ///
232    /// # Errors
233    ///
234    /// Returns the first violated law.
235    fn youtube_request_laws(&self) -> Result<()> {
236        let url = "https://www.youtube.com/watch?v=abc123";
237        if self.watch_request(url) != self.watch_request(url) {
238            bail!("a watch request is not a function of its URL");
239        }
240        if self.watch_request(url) == self.media_request(url) {
241            bail!("retrieving a watch page denotes the same request as retrieving media");
242        }
243        let claim =
244            PlayerClaim { client: "CLIENT".to_owned(), visitor: Some("session".to_owned()), timestamp: Some(19_000) };
245        let player = self.player_request("abc123", "key", &claim);
246        if player != self.player_request("abc123", "key", &claim) {
247            bail!("a player request is not a function of the values naming it");
248        }
249        if player == self.player_request("abc123", "other", &claim) {
250            bail!("a player request does not depend on its API key");
251        }
252        if player == self.player_request("other", "key", &claim) {
253            bail!("a player request does not depend on its video identity");
254        }
255        if player == self.player_request("abc123", "key", &PlayerClaim::default()) {
256            bail!("a player request does not depend on what the client claims about itself");
257        }
258        let other = PlayerClaim { client: "OTHER".to_owned(), ..claim.clone() };
259        if player == self.player_request("abc123", "key", &other) {
260            bail!("a player request does not depend on which client makes the claim");
261        }
262        Ok(())
263    }
264}
265
266/// Checks that decoding recovers the observations the transport bytes describe.
267#[ext(name = YoutubeResponseLaws)]
268pub impl<This> This
269where
270    This: YoutubeResponseAlg<Error: Debug> + YoutubeLawFixture,
271{
272    /// Checks that decoding is a function of the bytes and observes what they describe.
273    ///
274    /// The laws checked are:
275    ///
276    /// 1. decoding is a function of the bytes it reads;
277    /// 2. a watch page describing a playable video observes a player response and its API key;
278    /// 3. a player response describing one retrievable format observes it as locatable.
279    ///
280    /// # Errors
281    ///
282    /// Returns the first violated law, or the interpreter's decoding failure.
283    fn youtube_response_laws(&self) -> Result<()> {
284        let bytes = self.law_watch_bytes();
285        let page = match self.decode_youtube_watch(&bytes) {
286            Ok(page) => page,
287            Err(error) => bail!("decoding the watch page failed: {error:?}"),
288        };
289        if !page.has_player_response {
290            bail!("a watch page describing a playable video observes no player response");
291        }
292        if page.api_key.is_none() {
293            bail!("a watch page describing a playable video observes no API key");
294        }
295        match self.decode_youtube_watch(&bytes) {
296            Ok(again) if again == page => {}
297            _ => bail!("decoding a watch page is not a function of its bytes"),
298        }
299
300        let bytes = self.law_player_bytes();
301        let player = match self.decode_youtube_player(&bytes) {
302            Ok(player) => player,
303            Err(error) => bail!("decoding the player response failed: {error:?}"),
304        };
305        if player.status.as_deref() != Some("OK") {
306            bail!("a player response describing a playable video is not observed as playable");
307        }
308        if player.formats.is_empty() {
309            bail!("a player response describing one format observes none");
310        }
311        for format in &player.formats {
312            if format.source.url().is_empty() {
313                bail!("a described format states no location for its representation");
314            }
315        }
316        match self.decode_youtube_player(&bytes) {
317            Ok(again) if again == player => {}
318            _ => bail!("decoding a player response is not a function of its bytes"),
319        }
320        Ok(())
321    }
322}
323
324/// Checks that Youtube extraction denotes exactly the video its URL identifies.
325#[ext(name = YoutubeExtractionLaws)]
326pub impl<This, Request> This
327where
328    This: YoutubeSorts<Request = Request>
329        + YoutubeRequestAlg
330        + YoutubeClientAlg
331        + YoutubeProgramAlg
332        + YoutubeRequestBytesAlg<Error: Display>
333        + YoutubeResponseAlg<Error: Display>
334        + YoutubeChallengeAlg<Error: Display>
335        + YoutubeSolutionAlg
336        + YoutubeUrlAlg
337        + MetadataAlg
338        + FormatAlg
339        + ExtractionAlg
340        + ExtractionViewAlg
341        + MediaViewAlg
342        + YoutubeLawFixture,
343    Request: PartialEq + Debug,
344{
345    /// Checks that identity, request order, and extracted meaning follow the watch URL.
346    ///
347    /// The laws checked are:
348    ///
349    /// 1. the supported spellings of one video denote the same identity, and no other host does;
350    /// 2. an unsupported URL fails before any request is executed;
351    /// 3. extraction executes the watch request first, then the requests that page enables;
352    /// 4. extracting one video denotes one media item carrying that identity and its formats.
353    ///
354    /// # Errors
355    ///
356    /// Returns the first violated law.
357    fn youtube_extraction_laws(&self) -> Result<()> {
358        let watch_url = self.law_watch_url();
359        let id = self.law_video_id();
360        if youtube_id(&watch_url).as_deref() != Some(id.as_str()) {
361            bail!("the watch URL does not denote the identity the fixture states");
362        }
363        if youtube_id(&format!("https://youtu.be/{id}")).as_deref() != Some(id.as_str()) {
364            bail!("the short spelling of one video denotes another identity");
365        }
366        if let Some(other) = youtube_id("https://example.com/watch?v=abc123") {
367            bail!("an unsupported host denoted the video identity {other}");
368        }
369
370        let before = self.law_executed_requests();
371        match self.extract_youtube("https://example.com/watch?v=abc123") {
372            Err(YoutubeError::Url(_)) => {}
373            Ok(_) => bail!("an unsupported URL extracted"),
374            Err(error) => bail!("an unsupported URL failed as {error}"),
375        }
376        if self.law_executed_requests() != before {
377            bail!("an unsupported URL executed a request");
378        }
379
380        let extraction = match self.extract_youtube(&watch_url) {
381            Ok(extraction) => extraction,
382            Err(error) => bail!("extracting the fixture URL failed: {error}"),
383        };
384        let executed = self.law_executed_requests();
385        let performed = &executed[before.len()..];
386        if performed.len() < 2 {
387            bail!("extraction executed {} requests rather than several", performed.len());
388        }
389        if performed[0] != self.watch_request(&watch_url) {
390            bail!("extraction did not begin with the watch request: {:?}", performed[0]);
391        }
392        if performed[1..].contains(&performed[0]) {
393            bail!("extraction executed the watch request more than once");
394        }
395
396        let Some(media) = self.as_media(extraction) else {
397            bail!("extracting one video did not denote one media item");
398        };
399        if self.media_id(&media) != id {
400            bail!("the extracted media carries {} rather than {id}", self.media_id(&media));
401        }
402        if self.media_formats(&media).is_empty() {
403            bail!("the extracted media carries no format");
404        }
405        Ok(())
406    }
407}
408
409/// Checks that Youtube specialization hands its media request to generic download unchanged.
410#[ext(name = YoutubeApplicationLaws)]
411pub impl<This, Request, Event, Progress> This
412where
413    This: YoutubeSorts<Request = Request>
414        + YoutubeRequestAlg
415        + YoutubeClientAlg
416        + YoutubeProgramAlg
417        + YoutubeRequestBytesAlg<Error: Display>
418        + YoutubeResponseAlg<Error: Display>
419        + YoutubeChallengeAlg<Error: Display>
420        + YoutubeSolutionAlg
421        + YoutubeUrlAlg
422        + MetadataAlg
423        + FormatAlg
424        + FormatViewAlg
425        + FormatPredicateAlg
426        + FormatSelectionAlg
427        + ExtractionAlg
428        + ExtractionViewAlg
429        + MediaViewAlg
430        + FormatSelectionApplyAlg
431        + FormatSourceAlg<Request>
432        + FetchStreamAlg<Request, Error: Display>
433        + AtomicPublishAlg<Error: Display>
434        + DownloadObservationAlg<Event = Event, Progress = Progress>
435        + DownloadProgressAlg<Progress = Progress>
436        + DownloadReportAlg<Event = Event>
437        + YoutubeLawFixture
438        + MediaProgramLawFixture,
439    Request: PartialEq + Debug,
440{
441    /// Checks that one Youtube URL publishes exactly the bytes its granted representation denotes.
442    ///
443    /// The laws checked are:
444    ///
445    /// 1. the published path is named from the extracted identity and the selected format;
446    /// 2. retrieval opens exactly one request, and it is neither the watch nor the player request;
447    /// 3. the granted representation reaches generic download unchanged.
448    ///
449    /// # Errors
450    ///
451    /// Returns the first violated law.
452    fn youtube_application_laws(&mut self) -> Result<()> {
453        let watch_url = self.law_watch_url();
454        let selection = self.best_format(self.any_format());
455        let published = match self.download_youtube(&watch_url, &selection, &OutputTarget::MediaId) {
456            Ok(published) => published,
457            Err(error) => bail!("the Youtube download failed: {error}"),
458        };
459        let expected = PathBuf::from(format!("{}.{}", self.law_video_id(), self.law_extension()));
460        if published != expected {
461            bail!("naming derived {} rather than {}", published.display(), expected.display());
462        }
463        let retrieved = self.law_retrieved_requests();
464        if retrieved.len() != 1 {
465            bail!("retrieval opened {} requests rather than one", retrieved.len());
466        }
467        if retrieved[0] == self.watch_request(&watch_url) {
468            bail!("retrieval reused the watch request rather than the media request");
469        }
470        if self.law_published_bytes(&published).as_deref() != Some(&self.expected_law_bytes()[..]) {
471            bail!("the granted representation did not reach generic download unchanged");
472        }
473        Ok(())
474    }
475}
476
477/// Names the challenge a grant withheld, when it withheld one.
478#[must_use]
479pub fn withheld_challenge(grant: &YoutubeGrant) -> Option<&YoutubeChallenge> {
480    match grant {
481        YoutubeGrant::Withheld { challenge, .. } => Some(challenge),
482        YoutubeGrant::Granted { .. } | YoutubeGrant::Throttled { .. } => None,
483    }
484}
485
486/// States the player program a granting scenario poses its challenges under.
487fn law_program() -> &'static str {
488    "law://player-program"
489}
490
491/// Describes one format a granting scenario authors.
492fn law_format(id: &str, source: YoutubeFormatSource) -> YoutubeFormat {
493    YoutubeFormat {
494        id: id.to_owned(),
495        source,
496        extension: Some("mp4".to_owned()),
497        has_audio: true,
498        has_video: true,
499        quality: None,
500        width: None,
501        height: None,
502        bitrate: None,
503        content_length: None,
504        codecs: None,
505    }
506}