rusty_dl/
twitter.rs

1use std::{ffi::OsStr, path::Path};
2
3use self::{
4    details::{MediaEntity, TweetDetails, VideoInfo},
5    utils::RequestDetails,
6};
7use crate::{
8    header::HeaderMapBuilder,
9    prelude::{DownloadError, Downloader},
10    resource::ResourceDownloader,
11    twitter::{details::MediaType, utils::retrieve_request_details},
12};
13use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
14use regex::Regex;
15use reqwest::{header::HeaderValue, Client, Response, Url};
16use serde::Deserialize;
17
18mod details;
19pub mod utils;
20
21/*
22THIS MESSAGE IS COPY-PASTE FROM `https://github.com/inteoryx/twitter-video-dl.git` repository from which this [`TwitterDownloader`] is an implementation of.
23
24Here's how this works:
251. To download a video you need a Bearer Token and a guest token.  The guest token definitely expires and the Bearer Token could, though in practice I don't think it does.
262. Use the video id get both of those as if you were an unauthenticated browser.
273. Call "TweetDetails" graphql endpoint with your tokens.
284. TweetDetails response includes a 'medias' key which is a list of video urls and details.  Find the one with the highest bitrate (bigger is better, right?) and then just download that.
295. Some videos are small.  They are contained in a single mp4 file.  Other videos are big.  They have an mp4 file that's a "container" and then a bunch of m4s files.  Once we know the name of the video file we are looking for we can look up what the m4s files are, download all of them, and then put them all together into one big file.  This currently all happens in memory.  I would guess that a very huge video might cause an out of memory error.  I don't know, I haven't tried it.
305. If it's broken, fix it yourself because I'm very slow.  Or, hey, let me know, but I might not reply for months.
31
32
33Current state of work:
34
35Currently the "TweetDetails" endpoint is https://twitter.com/i/api/graphql/ncDeACNGIApPMaqGVuF_rw/TweetResultByRestId?variables={}&features={}
36
37Once we have both tokens, we generate the URL with all the variables and features, send a request to the endpoint
38with headers containing our tokens, retrieve the "TweetDetails,":
39
40now we need to extract the media download links, and finally download them!
41
42IN THE FUTURE:
43we should do the same as in the python version, that is whenever new variables and features are add, the program detects it and add them in the RequestDetails.json
44or maybe not because we do not want the crate to depend on any exterior file, that implies we should get rid of the json
45*/
46
47/// The `TwitterDownloader` is a Rust implementation inspired by the functionality of the `twitter-video-dl` python repository.
48#[derive(Clone)]
49pub struct TwitterDownloader {
50    /// The URL of the Twitter content.
51    url: Url,
52    /// The ID of the tweet.
53    tweet_id: String,
54    /// The status ID.
55    status_id: String,
56    /// Specifies the kind of media to download.
57    only_media_kind: Option<MediaKind>,
58    /// Callback function for naming downloaded media.
59    names_callback: fn(usize, TwitterMedia) -> String,
60    /// The name that should be given to all downloaded files
61    name_all: Option<String>,
62    /// The name that should be given to the downloaded file if there is only one
63    name_if_only_one_file: Option<String>,
64
65    print_download_status: bool,
66}
67
68/// Represents the kind of media to download from Twitter.
69#[derive(Debug, PartialEq, PartialOrd, Ord, Eq, Clone)]
70pub enum MediaKind {
71    /// Indicates an image.
72    Image,
73    /// Indicates a video.
74    Video,
75}
76
77#[derive(Debug, Deserialize)]
78struct GuestTokenResponse {
79    /// The guest token for accessing Twitter content.
80    guest_token: String,
81}
82
83impl TwitterDownloader {
84    /// Creates a new instance of [`TwitterDownloader`] with the provided Twitter tweet link.
85    ///
86    /// ## Arguments
87    ///
88    /// * `link` - The Twitter tweet link to download.
89    ///
90    /// ## Returns
91    ///
92    /// Returns a [`Result`] containing the [`TwitterDownloader`] instance on success, or a [`DownloadError`] if parsing the URL fails or if the URL is invalid.
93    ///
94    /// ## Examples
95    ///
96    /// ```no_run
97    /// use rusty_dl::prelude::TwitterDownloader;
98    ///
99    /// let link = "https://twitter.com/user/status/123456789";
100    /// let downloader = TwitterDownloader::new(link);
101    ///
102    /// assert!(downloader.is_ok());
103    /// ```    
104    pub fn new(link: &str) -> Result<Self, DownloadError> {
105        let url = Self::parse_url(
106            link,
107            Some("https://www.twitter.com/<USERNAME>/status/<TWEET_ID>"),
108        )?;
109
110        if !Self::is_valid_url(&url) {
111            return Err(DownloadError::InvalidUrl(
112                "Invalid URL! The domain must be either 'www.twitter.com' or 'www.x.com'."
113                    .to_owned(),
114            ));
115        }
116
117        let (status_id, tweet_id) = Self::extract_ids_from_url(&url)?;
118
119        Ok(Self {
120            url,
121            status_id,
122            tweet_id,
123            only_media_kind: None,
124            names_callback: |index: usize, media: TwitterMedia| {
125                let extension = media.extension();
126
127                let filename = extension.map_or_else(
128                    || format!("{}", index + 1),
129                    |ext| format!("{}.{}", index + 1, ext.to_string_lossy()),
130                );
131
132                filename
133            },
134            name_all: None,
135            name_if_only_one_file: None,
136            print_download_status: false,
137        })
138    }
139
140    /// Define a callback function to generate file names from.
141    ///
142    /// ## Arguments
143    ///
144    /// * `callback` - A function that takes a [`TwitterMedia`] instance and returns a [`String`].
145    ///
146    /// ## Returns
147    ///
148    /// Returns a mutable reference to the modified [`TwitterDownloader`]
149    ///
150    /// ## Examples
151    ///
152    /// ```
153    /// use rusty_dl::prelude::TwitterDownloader;
154    ///
155    /// let mut downloader = TwitterDownloader::new("https://twitter.com/user/status/123456789").unwrap();
156    ///
157    /// downloader.set_name_callback(|index, media| {
158    ///     format!("tweet_{}_{}", index + 1, media.extension().unwrap_or_default().to_string_lossy())
159    /// });
160    /// ```
161    pub fn set_name_callback(&mut self, callback: fn(usize, TwitterMedia) -> String) -> &mut Self {
162        self.names_callback = callback;
163
164        self
165    }
166
167    /// Set a given name for all the downloaded file.
168    ///
169    /// **THIS FUNCTION TAKES PRECEDENCE OVER `set_name_callback`.**
170    pub fn name_all(&mut self, value: String) -> &mut Self {
171        self.name_all = Some(value);
172
173        self
174    }
175
176    /// Set a given name for a downloaded media if the tweet only contains one media.
177    ///
178    /// **THIS FUNCTION TAKES PRECEDENCE OVER `set_name_callback` and `name_all`.**
179    pub fn name_if_only_file(&mut self, value: String) -> &mut Self {
180        self.name_if_only_one_file = Some(value);
181
182        self
183    }
184
185    /// Retrieves the media entities associated with the Twitter tweet.
186    ///
187    /// This method asynchronously fetches and returns the media entities (such as videos and images) associated with the Twitter tweet.
188    ///
189    /// ## Returns
190    ///
191    /// Returns a [`Result`] containing a vector of [`MediaEntity]` instances on success, or a [`DownloadError`] if the retrieval fails.
192    async fn get_tweet_medias(&self) -> Result<Vec<MediaEntity>, DownloadError> {
193        let (bearer_token, guest_token) = self.get_tokens().await?;
194
195        let tweet_details = self.get_tweet_details(&bearer_token, &guest_token).await?;
196
197        // medias contain all the informations regarding the tweet videos and images
198        let opt_medias = tweet_details.data.tweet_result.result.legacy.entities.media;
199
200        let medias = opt_medias.ok_or_else(|| {
201            DownloadError::TwitterError(format!(
202                "The tweet with ID `{}` does not contain any associated media.",
203                self.tweet_id()
204            ))
205        })?;
206
207        Ok(medias)
208    }
209
210    /// Extracts the status ID and tweet ID from the Twitter tweet URL.
211    fn extract_ids_from_url(url: &Url) -> Result<(String, String), DownloadError> {
212        let pattern = r"https://(twitter|x)\.com/([^/]+)/status/(\d+)";
213        let url_regex = Regex::new(pattern).unwrap();
214
215        if let Some(captures) = url_regex.captures(url.as_str()) {
216            if let (Some(status_id), Some(tweet_id)) = (captures.get(2), captures.get(3)) {
217                return Ok((status_id.as_str().to_owned(), tweet_id.as_str().to_owned()));
218            }
219        }
220
221        Err(DownloadError::TwitterError(format!(
222            "Failed to parse status_id and tweet_id from the tweet URL: `{}`",
223            url
224        )))
225    }
226
227    /// Sets the downloader to download only images from the Twitter tweet.
228    pub fn only_images(&mut self) -> &mut Self {
229        self.only_media_kind = Some(MediaKind::Image);
230        self
231    }
232
233    /// Sets the downloader to download only videos from the Twitter tweet.
234    pub fn only_videos(&mut self) -> &mut Self {
235        self.only_media_kind = Some(MediaKind::Video);
236        self
237    }
238
239    /// Returns the status ID of the Twitter tweet.
240    pub fn status_id(&self) -> &str {
241        &self.status_id
242    }
243    /// Returns the tweet ID of the Twitter tweet.
244    pub fn tweet_id(&self) -> &str {
245        &self.tweet_id
246    }
247    /// Returns the URL of the Twitter tweet.
248    pub fn url_str(&self) -> &str {
249        self.url.as_str()
250    }
251
252    /// Fetches the content of the Twitter tweet page asynchronously.
253    async fn fetch_page_content(url: &str) -> Result<String, DownloadError> {
254        let response = reqwest::get(url).await?;
255
256        if !response.status().is_success() {
257            return Err(DownloadError::TwitterError(format!(
258                "Failed to fetch content from URL: {}",
259                url
260            )));
261        }
262
263        response.text().await.map_err(|_| {
264            DownloadError::TwitterError(format!("Failed to read text from URL: {}", url))
265        })
266    }
267
268    /// Asynchronously retrieves the URL of the main JavaScript file from the Twitter tweet page.
269    async fn get_mainjs_url(&self) -> Result<String, DownloadError> {
270        let content = Self::fetch_page_content(self.url_str()).await?;
271
272        let main_js_regex =
273            Regex::new(r"https://abs.twimg.com/responsive-web/client-web-legacy/main\.[^.]+\.js")
274                .unwrap();
275        let mainjs_urls: Vec<&str> = main_js_regex
276            .find_iter(&content)
277            .map(|mat| mat.as_str())
278            .collect();
279
280        if mainjs_urls.is_empty() {
281            return Err(DownloadError::TwitterError(format!(
282                "Failed to retrieve `main.js` file from `{}` page.",
283                self.url
284            )));
285        }
286
287        Ok(mainjs_urls[0].to_owned())
288    }
289
290    /// Asynchronously retrieves the bearer token from the main JavaScript file URL.
291    async fn get_bearer_token(&self, mainjs_url: &str) -> Result<String, DownloadError> {
292        let main_js_content = Self::fetch_page_content(mainjs_url).await?;
293
294        let bearer_regex = Regex::new(r#"AAAAAAAAA[^\"']+"#).unwrap();
295        let bearer_tokens: Vec<&str> = bearer_regex
296            .find_iter(&main_js_content)
297            .map(|mat| mat.as_str())
298            .collect();
299
300        if bearer_tokens.is_empty() {
301            return Err(DownloadError::TwitterError(format!(
302                "Failed to find bearer token from `{}` page",
303                self.url
304            )));
305        }
306
307        let bearer_token = bearer_tokens[0];
308
309        Ok(bearer_token.to_owned())
310    }
311
312    /// Asynchronously retrieves the guest token using the provided bearer token.
313    async fn get_guest_token(&self, bearer_token: &str) -> Result<String, DownloadError> {
314        let client = Client::new();
315
316        let headers = HeaderMapBuilder::new()
317            .with_user_agent()
318            .accept("*/*")
319            .accept_language("fr,en-US;q=0.7,en;q=0.3")
320            .te("trailers")
321            .authorization(
322                HeaderValue::from_bytes(format!("Bearer {}", bearer_token).as_bytes())
323                    .expect("Failed to create HeaderValue"),
324            )
325            .build();
326
327        let body = client
328            .post("https://api.twitter.com/1.1/guest/activate.json")
329            .headers(headers)
330            .send()
331            .await
332            .map(|res| {
333                if !res.status().is_success() {
334                    return Err(DownloadError::TwitterError(format!(
335                        "Failed to find guest token from `{}` page",
336                        self.url
337                    )));
338                }
339
340                Ok(res)
341            })??
342            .text()
343            .await?;
344
345        serde_json::from_str::<GuestTokenResponse>(&body)
346            .map(|token_response| token_response.guest_token)
347            .map_err(|_| {
348                DownloadError::TwitterError(format!(
349                    "Failed to find guest token from `{}` page",
350                    self.url
351                ))
352            })
353    }
354
355    /// Asynchronously retrieves the bearer and guest tokens required for retrieving the tweet data next.
356    async fn get_tokens(&self) -> Result<(String, String), DownloadError> {
357        let mainjs_url = self.get_mainjs_url().await?;
358        let bearer_token = self.get_bearer_token(&mainjs_url).await?;
359        let guest_token = self.get_guest_token(&bearer_token).await?;
360
361        Ok((bearer_token, guest_token))
362    }
363
364    /// Asynchronously constructs the URL for retrieving tweet details.
365    async fn get_details_url(&self) -> Result<String, DownloadError> {
366        let RequestDetails {
367            mut variables,
368            features,
369        } = retrieve_request_details().await?;
370
371        variables.set_tweet_id(self.tweet_id().to_owned());
372
373        // Features and Variables structs serialized
374        let features_string = serde_json::to_string(&features).unwrap();
375        let variables_string = serde_json::to_string(&variables).unwrap();
376
377        // URL-encode the JSON string
378        let features_encoded = utf8_percent_encode(&features_string, NON_ALPHANUMERIC).to_string();
379        let variables_encoded =
380            utf8_percent_encode(&variables_string, NON_ALPHANUMERIC).to_string();
381
382        let url = format!("https://twitter.com/i/api/graphql/ncDeACNGIApPMaqGVuF_rw/TweetResultByRestId?variables={}&features={}", variables_encoded, features_encoded);
383
384        Ok(url)
385    }
386
387    /// Asynchronously sends a request to retrieve tweet details using bearer and guest tokens.
388    async fn retrieve_details(
389        &self,
390        bearer_token: &str,
391        guest_token: &str,
392    ) -> Result<Response, DownloadError> {
393        let url = self.get_details_url().await?;
394        let client = Client::new();
395
396        let headers = HeaderMapBuilder::new()
397            .with_user_agent()
398            .accept("*/*")
399            .accept_language("fr,en-US;q=0.7,en;q=0.3")
400            .te("trailers")
401            .authorization(
402                HeaderValue::from_bytes(format!("Bearer {}", bearer_token).as_bytes())
403                    .expect("Failed to create HeaderValue"),
404            )
405            .field(
406                "x-guest-token",
407                HeaderValue::from_str(guest_token).expect("Failed to create HeaderValue"),
408            )
409            .build();
410
411        let details = client.get(url).headers(headers).send().await?;
412        Ok(details)
413    }
414
415    /// Asynchronously retrieves tweet details using bearer and guest tokens.
416    async fn get_tweet_details(
417        &self,
418        bearer_token: &str,
419        guest_token: &str,
420    ) -> Result<TweetDetails, DownloadError> {
421        let details = self.retrieve_details(bearer_token, guest_token).await?;
422
423        // let mut try_count = 1;
424        // let max_tries = 11;
425        // should we update the loop to automatically add new variables if needed when the variables changes server side ??
426
427        if !details.status().is_success() {
428            return Err(DownloadError::TwitterError(format!(
429                "Failed to get details of tweet with id `{}`",
430                self.tweet_id()
431            )));
432        }
433
434        let response_text = details.text().await?;
435        let tweet_details = serde_json::from_str(&response_text).map_err(|_e| {
436            DownloadError::TwitterError("Failed to parse tweet details.".to_owned())
437        })?;
438
439        Ok(tweet_details)
440    }
441
442    // actually these are useless, we can just use `download_to`!!
443    // /// Downloads the Twitter tweet media and saves it to the specified folder with the tweet ID as the file name.
444    // ///
445    // /// ## Arguments
446    // ///
447    // /// * `path` - The path to the folder where the media will be downloaded.
448    // ///
449    // /// ## Returns
450    // ///
451    // /// Returns a [`Result`] indicating success or failure of the download operation.
452    // pub async fn download_as_tweets_folder_to<P: AsRef<std::path::Path> + Send>(
453    //     &self,
454    //     path: P,
455    // ) -> Result<(), DownloadError> {
456    //     let folder_path = path.as_ref();
457    //     let path_buf = folder_path.join(self.tweet_id());
458
459    //     self.download_to(path_buf).await
460    // }
461
462    // /// Blocks the current thread until the Twitter tweet media is downloaded and saved to the specified folder with the tweet ID as the file name.
463    // ///
464    // /// ## Arguments
465    // ///
466    // /// * `path` - The path to the folder where the media will be downloaded.
467    // ///
468    // /// ## Returns
469    // ///
470    // /// Returns a [`Result`] indicating success or failure of the download operation.
471    // pub fn blocking_download_as_tweets_folder_to(
472    //     &self,
473    //     path: &std::path::Path,
474    // ) -> Result<(), DownloadError>
475    // where
476    //     Self: Sync,
477    // {
478    //     Self::blocking(async { self.download_as_tweets_folder_to(path).await })
479    // }
480}
481
482#[async_trait::async_trait]
483impl Downloader for TwitterDownloader {
484    fn is_valid_url(url: &Url) -> bool {
485        url.domain() == Some("twitter.com")
486            || url.domain() == Some("x.com")
487            || url.domain() == Some("www.twitter.com")
488            || url.domain() == Some("www.x.com")
489    }
490
491    fn get_dl_status(&mut self) -> &mut bool {
492        &mut self.print_download_status
493    }
494
495    async fn download_to<P: AsRef<Path> + std::marker::Send>(
496        &self,
497        folder_path: P,
498    ) -> Result<(), DownloadError> {
499        let path = folder_path.as_ref();
500        let medias = self.get_tweet_medias().await?;
501
502        let media_infos = medias
503            .iter()
504            .map(|media_entity| {
505                media_entity.try_into().map_err(|e| {
506                    DownloadError::TwitterError(format!(
507                        "{} in `{}` tweet details.",
508                        e,
509                        self.tweet_id()
510                    ))
511                })
512            })
513            .collect::<Result<Vec<TwitterMedia>, DownloadError>>()?;
514
515        let download_links: Vec<TwitterMedia> = media_infos
516            .into_iter()
517            .filter(|x| TwitterMedia::filter_media_kind(x, self.only_media_kind.as_ref()))
518            .collect();
519
520        if self.print_download_status {
521            println!("Downloading...");
522        }
523
524        tokio::fs::create_dir_all(path).await?;
525
526        let number_of_files = download_links.len();
527
528        let results = futures::future::join_all(download_links.into_iter().enumerate().map(
529            |(index, media)| async move {
530                let url = media.url();
531
532                let mut rsrc_downloader = ResourceDownloader::new(url).map_err(|_| {
533                    DownloadError::TwitterError(format!("Invalid Media File path: `{}`", url))
534                })?;
535
536                let filename = if self.name_if_only_one_file.is_some() && number_of_files == 1 {
537                    self.name_if_only_one_file.as_ref().unwrap().to_owned()
538                } else if let Some(name) = self.name_all.as_ref() {
539                    let mut s = name.to_owned();
540
541                    if index != 0 {
542                        s.push_str(&format!(" ({})", index.to_string()));
543                    }
544
545                    s
546                } else {
547                    (self.names_callback)(index, media)
548                };
549
550                rsrc_downloader.with_name(filename);
551
552                let download_result = rsrc_downloader.download_to(&path).await;
553
554                if self.print_download_status {
555                    if let Err(err) = &download_result {
556                        eprintln!("Error downloading with url `{}`: {:?}", url, err);
557                    } else {
558                        println!("Media downloaded successfully: {}", url);
559                    }
560                }
561
562                download_result
563            },
564        ))
565        .await;
566
567        for result in results {
568            result?
569        }
570
571        Ok(())
572    }
573
574    /// Downloads and saves the twitter file(s) to the current working directory.
575    ///
576    /// ## Returns
577    ///
578    /// Returns a future representing the download operation, which resolves to a [`Result`] indicating success or failure.
579    ///
580    /// ## Examples
581    ///
582    /// ```no_run
583    /// use rusty_dl::prelude::{DownloadError, Downloader, TwitterDownloader};
584    ///
585    /// #[tokio::main]
586    /// async fn main() -> Result<(), DownloadError> {
587    ///     let downloader = TwitterDownloader::new("https://x.com/elonmusk/status/1776736700468990168").unwrap();
588    ///     let result = downloader.download().await?;
589    ///
590    ///     Ok(())
591    /// }
592    /// ```
593    async fn download(&self) -> Result<(), DownloadError> {
594        self.download_to("./").await
595    }
596
597    /// Blocks the current thread until the download completes, using asynchronous execution.
598    ///
599    /// The tweeter files are saved to the default location (`./`).
600    ///
601    /// ## Returns
602    ///
603    /// Returns a [`Result`] indicating success or failure of the download operation.
604    ///
605    /// ## Examples
606    ///
607    /// ```no_run
608    /// use rusty_dl::prelude::{DownloadError, Downloader, TwitterDownloader};
609    ///
610    /// fn main() -> Result<(), DownloadError> {
611    ///     let downloader = TwitterDownloader::new("https://x.com/SpaceX/status/1776412789768425751").unwrap();
612    ///     downloader.blocking_download()?;
613    ///
614    ///     Ok(())
615    /// }
616    /// ```
617    fn blocking_download(&self) -> Result<(), DownloadError>
618    where
619        Self: Sync,
620    {
621        Self::blocking(async { self.download().await })
622    }
623
624    /// Blocks the current thread until the download completes, using asynchronous execution, and saves the file at the specified path.
625    ///
626    /// ## Arguments
627    ///
628    /// * `path` - The path to the folder where the twitter files will be downloaded.
629    ///
630    /// ## Returns
631    ///
632    /// Returns a `Result` indicating success or failure of the download operation.
633    ///
634    /// ## Example
635    ///
636    /// ```no_run
637    /// use rusty_dl::prelude::{DownloadError, Downloader, TwitterDownloader};
638    ///
639    /// fn main() -> Result<(), DownloadError> {
640    ///     let downloader = TwitterDownloader::new("https://x.com/SpaceX/status/1776412650836251053").unwrap();
641    ///     downloader.blocking_download_to("./twitter_downloads/")?;
642    ///
643    ///     Ok(())
644    /// }
645    /// ```
646    fn blocking_download_to<P: AsRef<Path> + std::marker::Send>(
647        &self,
648        path: P,
649    ) -> Result<(), DownloadError>
650    where
651        Self: Sync,
652    {
653        Self::blocking(async { self.download_to(path).await })
654    }
655}
656
657/// Represents a media file from Twitter, such as an image or video.
658#[derive(Debug, Clone, Copy)]
659pub enum TwitterMedia<'a> {
660    /// Represents an image media file with its URL.
661    Image { url: &'a str },
662
663    /// Represents a video media file with its video information.
664    Video { infos: &'a VideoInfo },
665}
666
667impl<'a> TwitterMedia<'a> {
668    pub fn url(&self) -> &'a str {
669        match self {
670            TwitterMedia::Image { url } => *url,
671            TwitterMedia::Video { infos, .. } => {
672                let VideoInfo { variants, .. } = infos;
673
674                // For videos, take the one with the highest bitrate, i.e., highest quality.
675                let opt_variant = variants
676                    .iter()
677                    .max_by_key(|variant| variant.bitrate.unwrap_or(0));
678
679                let variant = opt_variant.unwrap();
680                &variant.url
681            }
682        }
683    }
684
685    pub fn extension(&self) -> Option<&OsStr> {
686        match self {
687            TwitterMedia::Image { url } => Path::new(url).extension(),
688            TwitterMedia::Video { infos, .. } => {
689                let VideoInfo { variants, .. } = infos;
690
691                // For videos, take the one with the highest bitrate, i.e., highest quality.
692                let opt_variant = variants
693                    .iter()
694                    .max_by_key(|variant| variant.bitrate.unwrap_or(0));
695
696                let variant = opt_variant.unwrap();
697                let extension = variant.content_type.split('/').nth(1);
698
699                extension.map(|s| OsStr::new(s))
700            }
701        }
702    }
703
704    fn filter_media_kind(&self, media_kind: Option<&MediaKind>) -> bool {
705        match media_kind {
706            None => true,
707            Some(_) if media_kind == Some(&MediaKind::Image) => match self {
708                TwitterMedia::Image { .. } => true,
709                _ => false,
710            },
711            Some(_) if media_kind == Some(&MediaKind::Video) => match self {
712                TwitterMedia::Video { .. } => true,
713                _ => false,
714            },
715            Some(_) => false,
716        }
717    }
718}
719
720impl<'a> TryFrom<&'a MediaEntity> for TwitterMedia<'a> {
721    type Error = String;
722
723    fn try_from(media_entity: &'a MediaEntity) -> Result<Self, Self::Error> {
724        match media_entity._type {
725            MediaType::Image => Ok(TwitterMedia::Image {
726                url: &media_entity.media_url_https,
727            }),
728            MediaType::Video | MediaType::Gif => Ok(TwitterMedia::Video {
729                infos: media_entity
730                    .video_info
731                    .as_ref()
732                    .ok_or("Media with type video but with no video info found".to_owned())?,
733            }),
734        }
735    }
736}