1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. //! Functionality to upload images, GIFs, and videos that can be attached to tweets. //! //! Tweet media is uploaded separately from the act of posting the tweet itself. In order to attach //! an image to a new tweet, you need to upload it first, then take the Media ID that Twitter //! generates and reference that when posting the tweet. The way this works in egg-mode is to //! create an [`UploadBuilder`] and turn that into an [`UploadFuture`], which manages the upload //! process. //! //! [`UploadBuilder`]: struct.UploadBuilder.html //! [`UploadFuture`]: struct.UploadFuture.html //! //! For example, here's a basic use of `UploadFuture` to upload an image, then attach it to a //! tweet: //! //! ```rust,no_run //! # use egg_mode::Token; //! # #[tokio::main] //! # async fn main() { //! # let token: Token = unimplemented!(); //! use egg_mode::media::{UploadBuilder, media_types}; //! use egg_mode::tweet::DraftTweet; //! //! let image = vec![]; //pretend we loaded an image file into this //! let builder = UploadBuilder::new(image, media_types::image_png()); //! let media_handle = builder.call(&token).await.unwrap(); //! //! let draft = DraftTweet::new("Hey, check out this cute cat!") //! .media_ids(&[media_handle.id]); //! let tweet = draft.send(&token).await.unwrap(); //! # } //! ``` //! //! For more information, see the [`UploadBuilder`] documentation. use std::borrow::Cow; use std::error::Error as StdError; use std::fmt; use std::future::Future; use std::pin::Pin; use std::task::{Context, Poll}; use std::time::{Duration, Instant}; use base64; use serde::de::Error; use serde::{Deserialize, Deserializer}; use tokio::time::{self, Delay}; use crate::common::*; use crate::error::Error::InvalidResponse; use crate::{auth, error, links}; use mime; /// A collection of convenience functions that return media types accepted by Twitter. /// /// These are convenience types that can be handed to [`UploadBuilder::new`] to set the right media /// type of a piece of media. The functions in the module correspond to media types that Twitter is /// known to accept. /// /// Note that using `image_gif` and `video_mp4` will automatically set the upload's /// `media_category` to `tweet_gif` and `tweet_video` respectively, allowing larger file sizes and /// extra processing time. /// /// [`UploadBuilder::new`]: ../struct.UploadBuilder.html#method.new pub mod media_types { use mime::{self, Mime}; /// PNG images. pub fn image_png() -> Mime { mime::IMAGE_PNG } /// JPG images. pub fn image_jpg() -> Mime { mime::IMAGE_JPEG } /// WEBP images. pub fn image_webp() -> Mime { "image/webp".parse().unwrap() } /// Animated GIF images. pub fn image_gif() -> Mime { mime::IMAGE_GIF } /// MP4 videos. pub fn video_mp4() -> Mime { "video/mp4".parse().unwrap() } } ///RawMedia's upload progressing info. #[derive(Debug, PartialEq)] enum ProgressInfo { ///Video is pending for processing. Contains number of seconds after which to check. Pending(u64), ///Video is beeing processed. Contains number of seconds after which to check. InProgress(u64), ///Video's processing failed. Contains reason. Failed(error::MediaError), ///Video's processing is finished. RawMedia can be used in other API calls. Success, } #[derive(Debug, Deserialize)] enum RawProgressInfoTag { #[serde(rename = "pending")] Pending, #[serde(rename = "in_progress")] InProgress, #[serde(rename = "failed")] Failed, #[serde(rename = "succeeded")] Success, } #[derive(Debug, Deserialize)] struct RawProgressInfo { state: RawProgressInfoTag, progress_percent: Option<f64>, check_after_secs: Option<u64>, error: Option<error::MediaError>, } impl<'de> Deserialize<'de> for ProgressInfo { fn deserialize<D>(deser: D) -> Result<ProgressInfo, D::Error> where D: Deserializer<'de>, { use self::RawProgressInfoTag::*; let raw = RawProgressInfo::deserialize(deser)?; let check_after = raw .check_after_secs .ok_or_else(|| D::Error::custom("Missing field: check_after_secs")); Ok(match raw.state { Pending => ProgressInfo::Pending(check_after?), InProgress => ProgressInfo::InProgress(check_after?), Success => ProgressInfo::Success, Failed => { let err = raw .error .ok_or_else(|| D::Error::custom("Missing field: error"))?; ProgressInfo::Failed(err) } }) } } /// A media handle returned by twitter upon successful upload. /// /// To get one of these, start with [`UploadBuilder`]. To use the `id` inside, see /// [`DraftTweet::media_ids`]. /// /// [`UploadBuilder`]: struct.UploadBuilder.html /// [`DraftTweet::media_ids`]: ../tweet/struct.DraftTweet.html#method.media_ids #[derive(Copy, Clone, Debug)] pub struct MediaHandle { /// The ID that can be used to reference the media. pub id: u64, /// The time after which the media will be rendered unusable in the twitter API. pub valid_until: Instant, } impl MediaHandle { /// Returns whether media is still valid to be used in API calls. /// /// Under hood it is `Instant::now() < handle.valid_until`. pub fn is_valid(&self) -> bool { Instant::now() < self.valid_until } } ///Represents media file that is uploaded on twitter. #[derive(Deserialize)] struct RawMedia { ///ID that can be used in API calls (e.g. attach to tweet). #[serde(rename = "media_id")] pub id: u64, ///Number of second the media can be used in other API calls. //We can miss this field on failed upload in which case 0 is pretty reasonable value. #[serde(default)] #[serde(rename = "expires_after_secs")] pub expires_after: u64, ///Progress information. If present determines whether RawMedia can be used. #[serde(rename = "processing_info")] pub progress: Option<ProgressInfo>, } impl RawMedia { fn into_handle(self) -> MediaHandle { MediaHandle { id: self.id, valid_until: Instant::now() + Duration::from_secs(self.expires_after), } } } /// Represents the kinda of media that Twitter will accept. #[derive(Debug, Copy, Clone, PartialEq, Eq)] enum MediaCategory { /// Static image. Four can be attached to a single tweet. Image, /// Animated GIF. Gif, /// Video. Video, } /// `Display` impl for `MediaCategory` so that `.to_string()` will return a string suitable for use /// in an API call. This will turn the enum into `"tweet_image"`, `"tweet_gif"`, and /// `"tweet_video"`. impl ::std::fmt::Display for MediaCategory { fn fmt(&self, fmt: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { match *self { MediaCategory::Image => write!(fmt, "tweet_image"), MediaCategory::Gif => write!(fmt, "tweet_gif"), MediaCategory::Video => write!(fmt, "tweet_video"), } } } /// Represents a media upload before it is sent. /// /// `UploadBuilder` is the entry point to uploading media to Twitter. /// It allows you to configure an upload and set the proper metadata. /// /// [`UploadFuture`]: struct.UploadFuture.html /// /// To begin setting up an upload, call `new` with your data and its media type. (Convenience /// functions to create `Mime` instances for types Twitter is known to accept are available in the /// [`media_types`] module.) With that, you can configure the upload, and finally start the /// process using the `call` method. See a basic example of using `UploadBuilder` to upload an image and attach it /// to a Tweet in [the module documentation]. /// /// [`media_types`]: media_types/index.html /// [the module documentation]: index.html /// /// To see more precise specifications for what media formats Twitter supports (resolution, file /// size, etc), see [their API documentation][media-best-practices]. Note that `UploadBuilder` /// automatically sets the underlying `media_category` to `tweet_gif` or `tweet_video` for /// `media_type`s of `"image/gif"` and `"video/mp4"` respectively. (Note that these are returned by /// [`media_types::image_gif`] and [`media_types::video_mp4`] as a convenience.) /// /// [media-best-practices]: https://developer.twitter.com/en/docs/media/upload-media/uploading-media/media-best-practices /// [`media_types::image_gif`]: media_types/fn.image_gif.html /// [`media_types::video_mp4`]: media_types/fn.video_mp4.html /// /// The lifetime parameter on `UploadBuilder` and [`UploadFuture`] is based on the data you hand to /// `new` and `alt_text`. Because they use `std::borrow::Cow` internally, if you hand them owned /// data (`Vec` or `String`), the resulting [`UploadFuture`] will have lifetime `'static`. pub struct UploadBuilder<'a> { data: Cow<'a, [u8]>, media_type: mime::Mime, chunk_size: Option<usize>, category: MediaCategory, alt_text: Option<Cow<'a, str>>, } impl<'a> UploadBuilder<'a> { /// Creates a new instance of `UploadBuilder` with the given data and media type. /// /// For convenience functions to get known `media_type`s that Twitter will accept, see the /// [`media_types`] module. /// /// [`media_types`]: media_types/index.html pub fn new<V: Into<Cow<'a, [u8]>>>(data: V, media_type: mime::Mime) -> UploadBuilder<'a> { let category = if media_type == media_types::image_gif() { MediaCategory::Gif } else if media_type == media_types::video_mp4() { MediaCategory::Video } else { MediaCategory::Image }; UploadBuilder { data: data.into(), media_type, chunk_size: None, category, alt_text: None, } } /// Sets how many bytes to upload in one network call. By default this is set to 512 KiB. /// /// `UploadFuture` uses Twitter's chunked media upload under-the-hood, and this allows you to /// set the size of each chunk. /// With a smaller chunk size, Twitter can "save" the data more often. /// However, there's also network overhead, since each chunk needs a separate HTTP request. /// Larger chunk sizes are better for stable network conditions, where you can reasonably expect a large upload to succeed. /// Note that once the `UploadFuture` is created, the chunk size cannot be changed. pub fn chunk_size(self, chunk_size: usize) -> Self { UploadBuilder { chunk_size: Some(chunk_size), ..self } } /// Applies the given alt text to the media when the upload is finished. pub fn alt_text<S: Into<Cow<'a, str>>>(self, alt_text: S) -> Self { UploadBuilder { alt_text: Some(alt_text.into()), ..self } } /// Starts the upload process and returns a `Future` that represents it. pub fn call(self, token: &auth::Token) -> UploadFuture<'a> { UploadFuture { data: self.data, media_type: self.media_type, media_category: self.category, timeout: Instant::now(), token: token.clone(), chunk_size: self.chunk_size.unwrap_or(1024 * 512), // 512 KiB default alt_text: self.alt_text, status: UploadInner::PreInit, } } } /// A `Future` that represents an in-progress media upload. /// /// This struct is obtained from an [`UploadBuilder`]. See those docs for specifics on creating /// one, and [the module docs] for more information on how to upload media in general. /// /// [`UploadBuilder`]: struct.UploadBuilder.html /// [the module docs]: index.html /// /// # Errors /// /// Because `UploadFuture` represents a potentially long-running upload, it's set up so that if it /// fails at any point in the process, it will retry its last action upon its next `poll`. This /// also includes keeping its place in terms of how many chunks it's uploaded so far. /// /// There's a complicating factor for this, though: Twitter only allows an upload session to be /// active for a limited time. `UploadFuture` keeps track of when the session expires, and /// restarts the upload if it's `poll`ed from an error state when the time has elapsed. (Note that /// timeout is checked only in case of errors. If the last action was a successful one, /// it will send off the next action to Twitter, likely receiving an error for /// that, after which it will restart the upload.) This timeout is reflected in the [`UploadError`] /// it returns in any error case. /// /// [`UploadError`]: struct.UploadError.html /// /// To allow for better handling of individual errors and better retry logic, the [`UploadError`] /// also includes a mention of which state the `UploadFuture` was in before encountering the error. /// If the Future was attempting to upload an individual chunk, or finalizing the upload session, /// and the Future encountered a network error, it should be safe to retry the Future if network /// conditions improve before the timeout elapses. (The precise [`Error`] which was encountered is /// also included in the returned `UploadError`.) /// /// [`Error`]: ../error/enum.Error.html /// /// (Proper mechanisms for actually integrating retry logic into your application is beyond the /// scope of this library. There are dedicated libraries for retry logic, or you can use the /// built-in `shared` function on all `Futures`s to get a cloneable handle to a `Future` so you can /// keep a handle to send back into an executor. This is all just to say that an `UploadFuture` is /// not invalidated when it returns an `Err` from `poll`.) /// /// # Lifetimes /// /// The lifetime parameter to `UploadFuture` is based on the data (and alt text) given to the /// `UploadBuilder` that created it. If an owned `Vec` (and either no alt text or an owned /// `String`) was given to the `UploadBuilder`, this future will have a `'static` lifetime. #[must_use = "futures do nothing unless polled"] pub struct UploadFuture<'a> { data: Cow<'a, [u8]>, media_type: mime::Mime, media_category: MediaCategory, timeout: Instant, token: auth::Token, chunk_size: usize, alt_text: Option<Cow<'a, str>>, status: UploadInner, } /// The current status of an `UploadFuture`. enum UploadInner { /// The `UploadFuture` has yet to initialize the upload session. PreInit, /// The `UploadFuture` is waiting to initialize the media upload session. WaitingForInit(FutureResponse<RawMedia>), /// The `UploadFuture` is in the progress of uploading data. UploadingChunk(u64, usize, FutureResponse<()>), /// The `UploadFuture` failed to upload a chunk of data and is waiting to re-send it. FailedChunk(u64, usize), /// The `UploadFuture` is currently finalizing the media with Twitter. Finalizing(u64, FutureResponse<RawMedia>), /// The `UploadFuture` failed to finalize the upload session, and is waiting to retry. FailedFinalize(u64), /// The `UploadFuture` is waiting on Twitter to finish processing a video or gif. PostProcessing(u64, Delay), /// The `UploadFuture` is waiting on Twitter to apply metadata to the uploaded image. Metadata(MediaHandle, FutureResponse<()>), /// The `UploadFuture` failed to update metadata on the media. FailedMetadata(MediaHandle), /// The `UploadFuture` has completed, or has encountered an error. Invalid, } impl<'a> UploadFuture<'a> { fn get_chunk(&self, chunk_num: usize) -> Option<&[u8]> { let start = chunk_num * self.chunk_size; let end = (chunk_num + 1) * self.chunk_size; if start >= self.data.len() { None } else if end >= self.data.len() { Some(&self.data[start..]) } else { Some(&self.data[start..end]) } } fn init(&self) -> impl Future<Output = error::Result<Response<RawMedia>>> { let params = ParamList::new() .add_param("command", "INIT") .add_param("total_bytes", self.data.len().to_string()) .add_param("media_type", self.media_type.to_string()) .add_param("media_category", self.media_category.to_string()); let req = auth::post(links::media::UPLOAD, &self.token, Some(¶ms)); make_parsed_future(req) } fn append( &self, chunk_num: usize, media_id: u64, ) -> Option<impl Future<Output = error::Result<Response<()>>>> { let mut chunk = self.get_chunk(chunk_num); if chunk.is_none() && chunk_num == 0 { chunk = Some(&[][..]); } if let Some(chunk) = chunk { let params = ParamList::new() .add_param("command", "APPEND") .add_param("media_id", media_id.to_string()) .add_param("media_data", base64::encode(chunk)) .add_param("segment_index", chunk_num.to_string()); let req = auth::post(links::media::UPLOAD, &self.token, Some(¶ms)); fn parse_resp( full_resp: String, headers: &Headers, ) -> Result<Response<()>, error::Error> { if full_resp.is_empty() { Response::unit(headers) } else { Err(InvalidResponse("Expected empty response", Some(full_resp))) } } Some(make_future(req, parse_resp)) } else { None } } fn finalize(&self, media_id: u64) -> impl Future<Output = error::Result<Response<RawMedia>>> { let params = ParamList::new() .add_param("command", "FINALIZE") .add_param("media_id", media_id.to_string()); let req = auth::post(links::media::UPLOAD, &self.token, Some(¶ms)); make_parsed_future(req) } fn status(&self, media_id: u64) -> impl Future<Output = error::Result<Response<RawMedia>>> { let params = ParamList::new() .add_param("command", "STATUS") .add_param("media_id", media_id.to_string()); let req = auth::get(links::media::UPLOAD, &self.token, Some(¶ms)); make_parsed_future(req) } fn metadata( &self, media_id: u64, alt_text: &str, ) -> impl Future<Output = error::Result<Response<()>>> { use serde_json::map::Map; use serde_json::Value; let mut inner = Map::new(); inner.insert("text".to_string(), Value::String(alt_text.to_string())); let mut outer = Map::new(); outer.insert("media_id".to_string(), Value::String(media_id.to_string())); outer.insert("alt_text".to_string(), Value::Object(inner)); let body = Value::Object(outer); let req = auth::post_json(links::media::METADATA, &self.token, &body); fn parse_resp(full_resp: String, headers: &Headers) -> Result<Response<()>, error::Error> { if full_resp.is_empty() { Response::unit(headers) } else { Err(InvalidResponse("Expected empty response", Some(full_resp))) } } make_future(req, parse_resp) } } impl<'a> Future for UploadFuture<'a> { type Output = Result<MediaHandle, UploadError>; fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> { use std::mem::replace; match replace(&mut self.status, UploadInner::Invalid) { UploadInner::PreInit => { self.status = UploadInner::WaitingForInit(Box::pin(self.init())); self.poll(cx) } UploadInner::WaitingForInit(mut init) => { match Pin::new(&mut init).poll(cx) { Poll::Pending => { self.status = UploadInner::WaitingForInit(init); Poll::Pending } Poll::Ready(Ok(media)) => { self.timeout = Instant::now() + Duration::from_secs(media.expires_after); let id = media.id; //chunk zero is guaranteed to return *something*, even an empty slice let loader = self.append(0, id).unwrap(); self.status = UploadInner::UploadingChunk(id, 0, Box::pin(loader)); self.poll(cx) } Poll::Ready(Err(e)) => { self.status = UploadInner::PreInit; Poll::Ready(Err(UploadError::initialize(e))) } } } UploadInner::UploadingChunk(id, chunk_idx, mut upload) => { match Pin::new(&mut upload).poll(cx) { Poll::Pending => { self.status = UploadInner::UploadingChunk(id, chunk_idx, upload); Poll::Pending } Poll::Ready(Ok(_)) => { let chunk_idx = chunk_idx + 1; if let Some(upload) = self.append(chunk_idx, id) { self.status = UploadInner::UploadingChunk(id, chunk_idx, Box::pin(upload)); } else { let loader = self.finalize(id); self.status = UploadInner::Finalizing(id, Box::pin(loader)); } self.poll(cx) } Poll::Ready(Err(e)) => { self.status = UploadInner::FailedChunk(id, chunk_idx); Poll::Ready(Err(UploadError::chunk(self.timeout, e))) } } } UploadInner::FailedChunk(id, chunk_idx) => { if Instant::now() >= self.timeout { //we've timed out, restart the upload self.status = UploadInner::PreInit; self.poll(cx) } else if let Some(upload) = self.append(chunk_idx, id) { self.status = UploadInner::UploadingChunk(id, chunk_idx, Box::pin(upload)); self.poll(cx) } else { //this... should never happen? the FailedChunk status means that this specific //id/index should have yielded a chunk before. unreachable!() } } UploadInner::Finalizing(id, mut finalize) => { match Pin::new(&mut finalize).poll(cx) { Poll::Pending => { self.status = UploadInner::Finalizing(id, finalize); Poll::Pending } Poll::Ready(Ok(media)) => { if media.progress.is_none() || media.progress == Some(ProgressInfo::Success) { let media = media.response.into_handle(); self.timeout = media.valid_until; let loader = self.alt_text.as_ref().map(|txt| self.metadata(id, txt)); if let Some(loader) = loader { self.status = UploadInner::Metadata(media, Box::pin(loader)); return self.poll(cx); } else { return Poll::Ready(Ok(media)); } } match media.response.progress { Some(ProgressInfo::Pending(time)) | Some(ProgressInfo::InProgress(time)) => { self.timeout = Instant::now() + Duration::from_secs(media.expires_after); //TODO: oh hey we needed the handle for something - we need to use //new-tokio to fix this let delay = Duration::from_secs(time); let timer = time::delay_for(delay); self.status = UploadInner::PostProcessing(media.id, timer); self.poll(cx) } Some(ProgressInfo::Failed(err)) => { self.status = UploadInner::FailedFinalize(id); Poll::Ready(Err(UploadError::finalize( self.timeout, error::Error::MediaError(err), ))) } None | Some(ProgressInfo::Success) => unreachable!(), } } Poll::Ready(Err(e)) => { self.status = UploadInner::FailedFinalize(id); Poll::Ready(Err(UploadError::finalize(self.timeout, e))) } } } UploadInner::FailedFinalize(id) => { if Instant::now() >= self.timeout { //we've timed out, restart the upload self.status = UploadInner::PreInit; } else { let finalize = self.finalize(id); self.status = UploadInner::Finalizing(id, Box::pin(finalize)); } self.poll(cx) } UploadInner::PostProcessing(id, mut timer) => match Pin::new(&mut timer).poll(cx) { Poll::Pending => { self.status = UploadInner::PostProcessing(id, timer); Poll::Pending } Poll::Ready(()) => { let loader = self.status(id); self.status = UploadInner::Finalizing(id, Box::pin(loader)); self.poll(cx) } }, UploadInner::Metadata(media, mut loader) => match Pin::new(&mut loader).poll(cx) { Poll::Pending => { self.status = UploadInner::Metadata(media, loader); Poll::Pending } Poll::Ready(Ok(_)) => Poll::Ready(Ok(media)), Poll::Ready(Err(e)) => { self.status = UploadInner::FailedMetadata(media); Poll::Ready(Err(UploadError::metadata(self.timeout, e))) } }, UploadInner::FailedMetadata(media) => { if Instant::now() >= self.timeout { //we've timed out, restart the upload self.status = UploadInner::PreInit; } else if let Some(ref alt_text) = self.alt_text { let loader = self.metadata(media.id, alt_text); self.status = UploadInner::Metadata(media, Box::pin(loader)); } else { //if we uploaded metadata then we should have had alt text to begin with unreachable!(); } self.poll(cx) } UploadInner::Invalid => Poll::Ready(Err(UploadError::complete())), } } } /// A wrapper for `UploadFuture` errors, noting at which stage of the upload the error occurred at. /// /// Since [`UploadFuture`] can retry its last action after an error, the error it returns includes /// additional information to allow for smarter retry logic if necessary. See the [`UploadFuture`] /// documentation for more details. /// /// [`UploadFuture`]: struct.UploadFuture.html #[derive(Debug)] pub struct UploadError { /// The stage of upload that the error occurred at. pub state: UploadState, /// The time when the `UploadFuture` will no longer be valid. /// /// Since Twitter only allows upload sessions to be open for a limited time period, /// `UploadFuture` will automatically restart upload sessions if it detects that the timeout /// has elapsed after a previous error. Note that even completed upload sessions have a timeout /// for how long the ID can be used to attach the media, which is also reflected here if /// `state` is `Metadata`. /// /// Note that if `state` is `Initialize` or `Complete`, this field is invalid, and is set to a /// dummy value (specifically `Instant::now()`). pub timeout: Instant, /// The error that occurred in `UploadFuture`. pub error: error::Error, } impl UploadError { fn initialize(error: error::Error) -> UploadError { UploadError { state: UploadState::Initialize, timeout: Instant::now(), error: error, } } fn chunk(timeout: Instant, error: error::Error) -> UploadError { UploadError { state: UploadState::ChunkUpload, timeout: timeout, error: error, } } fn finalize(timeout: Instant, error: error::Error) -> UploadError { UploadError { state: UploadState::Finalize, timeout: timeout, error: error, } } fn metadata(timeout: Instant, error: error::Error) -> UploadError { UploadError { state: UploadState::Metadata, timeout: timeout, error: error, } } fn complete() -> UploadError { UploadError { state: UploadState::Complete, timeout: Instant::now(), error: error::Error::FutureAlreadyCompleted, } } } /// Represents the status of an `UploadFuture`. /// /// This is a representation of the distinct phases of an [`UploadFuture`], given as part of an /// [`UploadError`]. See the [`UploadFuture`] documentation for details. /// /// [`UploadFuture`]: struct.UploadFuture.html /// [`UploadError`]: struct.UploadError.html #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] pub enum UploadState { /// The `UploadFuture` was trying to initialize the upload session. Initialize, /// The `UploadFuture` was trying to upload a chunk of the media file. ChunkUpload, /// The `UploadFuture` was trying to finalize the upload session. Finalize, /// The `UploadFuture` was trying to apply alt-text metadata to the media after finalizing the /// upload session. Metadata, /// The `UploadFuture` was fully completed, or previously encountered an error that dropped it /// out of the upload process. Complete, } impl fmt::Display for UploadError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, "Upload error during {:?}: \"{}\"", self.state, self.error ) } } impl StdError for UploadError { fn description(&self) -> &str { "error occurred while uploading media" } fn cause(&self) -> Option<&dyn StdError> { Some(&self.error) } } #[cfg(test)] mod tests { use super::RawMedia; use crate::common::tests::load_file; fn load_media(path: &str) -> RawMedia { let content = load_file(path); ::serde_json::from_str::<RawMedia>(&content).unwrap() } #[test] fn parse_media() { let media = load_media("sample_payloads/media.json"); assert_eq!(media.id, 710511363345354753); assert_eq!(media.expires_after, 86400); } #[test] fn parse_media_pending() { let media = load_media("sample_payloads/media_pending.json"); assert_eq!(media.id, 13); assert_eq!(media.expires_after, 86400); assert!(media.progress.is_some()); match media.progress { Some(super::ProgressInfo::Pending(5)) => (), other => assert!(false, format!("Unexpected value of progress={:?}", other)), } } #[test] fn parse_media_in_progress() { let media = load_media("sample_payloads/media_in_progress.json"); assert_eq!(media.id, 13); assert_eq!(media.expires_after, 3595); assert!(media.progress.is_some()); match media.progress { Some(super::ProgressInfo::InProgress(10)) => (), other => assert!(false, format!("Unexpected value of progress={:?}", other)), } } #[test] fn parse_media_fail() { let media = load_media("sample_payloads/media_fail.json"); assert_eq!(media.id, 710511363345354753); assert_eq!(media.expires_after, 0); assert!(media.progress.is_some()); match media.progress { Some(super::ProgressInfo::Failed(error)) => assert_eq!( error, crate::error::MediaError { code: 1, name: "InvalidMedia".to_string(), message: "Unsupported video format".to_string(), } ), other => assert!(false, format!("Unexpected value of progress={:?}", other)), } } }