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}