x-uploads 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +171 -0
- data/LICENSE.txt +21 -0
- data/README.md +98 -0
- data/lib/x/uploads/account.rb +100 -0
- data/lib/x/uploads/alt_text_failed.rb +81 -0
- data/lib/x/uploads/api.rb +310 -0
- data/lib/x/uploads/chunked_upload_failed.rb +89 -0
- data/lib/x/uploads/chunks.rb +325 -0
- data/lib/x/uploads/error.rb +25 -0
- data/lib/x/uploads/gif.rb +88 -0
- data/lib/x/uploads/invalid_media.rb +19 -0
- data/lib/x/uploads/invalid_media_type.rb +12 -0
- data/lib/x/uploads/json_classes.rb +14 -0
- data/lib/x/uploads/media_processing_check_failed.rb +85 -0
- data/lib/x/uploads/media_processing_failed.rb +43 -0
- data/lib/x/uploads/media_processing_timeout.rb +55 -0
- data/lib/x/uploads/media_upload.rb +565 -0
- data/lib/x/uploads/metadata.rb +97 -0
- data/lib/x/uploads/missing_media_data.rb +47 -0
- data/lib/x/uploads/multipart.rb +63 -0
- data/lib/x/uploads/signature.rb +104 -0
- data/lib/x/uploads/source.rb +440 -0
- data/lib/x/uploads/uploaded_media.rb +369 -0
- data/lib/x/uploads/utils.rb +330 -0
- data/lib/x/uploads/validator.rb +391 -0
- data/lib/x/uploads/version.rb +24 -0
- data/lib/x/uploads.rb +18 -0
- data/sig/manifest.yaml +6 -0
- data/sig/x-uploads.rbs +139 -0
- metadata +98 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 26e5c0c632a43dd3eac1253aea99b205c34f1f85bd6a2fb1be66101839f9aa48
|
|
4
|
+
data.tar.gz: c8df4c5e74eb1aa29fca43f12d90e4dd0352994d19420eb33c36cbcd69e4566f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e671ffd8471e766b697d0b6f81ebf93dec6de363d91bdf095c68703021bd35e41a725843713518fbca54ed502a45db3c99a2c8b5844ca7dc68cbb5d623b4cee2
|
|
7
|
+
data.tar.gz: e6ba94e7f4a742d49704c40ae8b71bc55815ace9562b437df872d0cabdef9e70cc525b806d092737bb47319df4ffa0806406c4626aad38af4011a66946fe503f
|
data/.yardopts
ADDED
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `x-uploads` will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
`x-uploads` is released in lockstep with the other gems of the [x-ruby](https://github.com/sferik/x-ruby) repository, at one version across `x-core`, `x-uploads`, `x-streams`, `x-resources`, and `x`. This file holds the changes to the uploads; [the changelog of the repository](https://github.com/sferik/x-ruby/blob/main/CHANGELOG.md) holds the changes to every gem.
|
|
9
|
+
|
|
10
|
+
## [1.0.0] - 2026-10-06
|
|
11
|
+
|
|
12
|
+
The first release of `x-uploads`, which 1.0.0 split out of the `x` gem. The entries below are the changes since `x` 0.19, the last release before the split; see [UPGRADING.md](https://github.com/sferik/x-ruby/blob/main/UPGRADING.md) for the changes that code written for 0.19 needs.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
* Add `X::Uploads.gem_version`, which returns `VERSION` as a `Gem::Version`
|
|
16
|
+
* Split `x` into gems released in lockstep: `x-core`, `x-uploads`, `x-streams`, `x-resources`, and the `x` meta-gem
|
|
17
|
+
* `x-core` is the HTTP client and declares `X::Error`, the base of every error the gems raise
|
|
18
|
+
* `x-uploads` holds the media, profile image, and banner uploads
|
|
19
|
+
* Public classes are named directly under `X`, whichever gem declares them
|
|
20
|
+
* Rescue one gem's failures with `X::Uploads::Error`, `X::Resources::Error`, or `X::Streams::Error`
|
|
21
|
+
* `x` depends on exactly its own version of the other four; `x-uploads`, `x-streams`, and `x-resources` each depend on `x-core` with `>= 1.0.0, < 2`
|
|
22
|
+
* Make `X::Uploads::MediaUpload.upload` handle any file: it infers the category, chunks videos, and awaits processing
|
|
23
|
+
* The category is inferred from the bytes the media begins with, or else from its file extension
|
|
24
|
+
* It takes the `media_type:`, `chunk_size:`, and `concurrency:` of a chunked upload; others raise `ArgumentError`
|
|
25
|
+
* Add upload methods to `X::Client` with `X::Uploads::API`, which `x` includes into `X::Client`
|
|
26
|
+
* `upload_media`, `chunked_upload_media`, `await_media_processing`, and `await_media_processing!`
|
|
27
|
+
* `add_alt_text`, `add_subtitles`, `update_profile_image`, and `update_profile_banner`
|
|
28
|
+
* `chunked_upload_media` returns once the upload is finalized; wait with `await_media_processing(!)` when needed
|
|
29
|
+
* Media that already says its processing ended, or an image's upload response, is awaited without a request
|
|
30
|
+
* With `x-core` and `x-uploads` alone, include it yourself: `X::Client.include(X::Uploads::API)`
|
|
31
|
+
* Passing `client:` to any of them raises `ArgumentError`
|
|
32
|
+
* Return an `X::UploadedMedia` in place of a Hash from every upload, wait, and metadata method
|
|
33
|
+
* It reads `id` and `media_id` as Integers, and `media_key`, `bytesize`, `expires_after_secs`, `processing_info`, `state`, and `check_after_secs`
|
|
34
|
+
* `processing?`, `failed?`, and `ready?` tell the state of its processing
|
|
35
|
+
* It still reads as a Hash with `[]`, `fetch`, `dig`, `key?`, and `to_h`, so `media["id"]` keeps working
|
|
36
|
+
* `add_alt_text` and `add_subtitles` return the media they describe, so a call chains to the upload
|
|
37
|
+
* It is frozen, and raises `ArgumentError` unless built with an `"id"` of 1 to 19 digits, as an Integer or a String
|
|
38
|
+
* Its Marshal and YAML formats are read by every 1.x release; an unknown one raises `X::UnsupportedFormat`
|
|
39
|
+
* Add alt text to uploaded media with the `alt_text:` of `upload`, or with `X::Uploads::Metadata.add_alt_text`
|
|
40
|
+
* Alt text that is not a String of 1 to 1,000 characters convertible to UTF-8 raises `ArgumentError` before a request
|
|
41
|
+
* `upload` validates `alt_text:` before uploading, so media is not lost to rejected alt text
|
|
42
|
+
* An upload that cannot add its alt text raises `X::AltTextFailed`, which holds the uploaded `media`, for any `StandardError` but a `Timeout::Error` or an `X::TokenReportFailed`, which are raised as they are, as an interrupt is, but a timeout raised with its class, as by `Timeout.timeout(5, Timeout::Error)`, that lands in `save_tokens` is an `X::TokenReportFailed`, holding the tokens
|
|
43
|
+
* Attach uploaded subtitles to a video with `X::Uploads::Metadata.add_subtitles`
|
|
44
|
+
* `media_category:` defaults to `"tweet_video"`, and takes `tweet_video` or `amplify_video` in any case
|
|
45
|
+
* It also takes `TweetVideo` or `AmplifyVideo`, as the endpoint names them; any other category raises `ArgumentError`
|
|
46
|
+
* A language code that is not two letters raises `ArgumentError` before a request; it is sent upcased
|
|
47
|
+
* Accept a media ID, a media key, an upload response, or anything that answers `media_key` as uploaded media
|
|
48
|
+
* In `await_processing`, `await_processing!`, `add_alt_text`, and `add_subtitles`, and their client methods
|
|
49
|
+
* Anything else, nil, media without an `"id"`, or an ID that is not 1 to 19 digits raises `ArgumentError`
|
|
50
|
+
* Share uploaded media with the `shared:` and `additional_owners:` of `upload`, `chunked_upload`, and `upload_media`
|
|
51
|
+
* Shared media always uploads in chunks
|
|
52
|
+
* An invalid `shared` or `additional_owners` raises `ArgumentError` before any request
|
|
53
|
+
* Upload a GIF with a single frame as an image, since X fails to process it as a GIF
|
|
54
|
+
* Take the media as the positional argument of the `X::Uploads::MediaUpload` and `X::Uploads::Account` methods
|
|
55
|
+
* `client:` is a keyword, as in the object layer
|
|
56
|
+
* Name a media category with a Symbol, in any case, as in `media_category: :tweet_video`
|
|
57
|
+
* Add `X::Uploads::MediaUpload::DEFAULT_CONCURRENCY` (4) and `MAX_CONCURRENCY` (16), the chunks sent at once
|
|
58
|
+
* Add `X::Uploads::MediaUpload::DEFAULT_PROCESSING_TIMEOUT` (600 seconds) and the `AMPLIFY_VIDEO` category constant
|
|
59
|
+
* Add `X::Uploads::Error`, the base of the errors `x-uploads` raises itself
|
|
60
|
+
* `X::AltTextFailed`, `X::ChunkedUploadFailed`, `X::InvalidMedia`, and `X::MediaProcessingCheckFailed` descend from it
|
|
61
|
+
* So do `X::MediaProcessingFailed`, `X::MediaProcessingTimeout`, and `X::MissingMediaData`
|
|
62
|
+
* `X::InvalidMedia` and its subclass `X::InvalidMediaType` mean media the API would refuse
|
|
63
|
+
* Mistakes in arguments, such as a bad category, chunk size, or timeout, raise `ArgumentError` instead
|
|
64
|
+
* Each error that holds media reads it as an `X::UploadedMedia` with `media`, and can be raised with a message alone
|
|
65
|
+
* Raise `X::MediaProcessingCheckFailed`, which holds the uploaded `media`, when an upload's processing check fails
|
|
66
|
+
* Raise `X::ChunkedUploadFailed`, which holds the initialized `media`, when a chunk or the finalize request fails
|
|
67
|
+
* Also when the file is deleted, closed, or shrinks during the upload, or the finalize response holds no media
|
|
68
|
+
* It and `X::MediaProcessingCheckFailed` are raised for any `StandardError`, as the `cause`, such as one `on_response` raises
|
|
69
|
+
* A `Timeout::Error`, an `X::MediaProcessingTimeout`, or an interrupt is raised as it is, but a timeout raised with its class, as by `Timeout.timeout(5, Timeout::Error)`, that lands in `save_tokens` is an `X::TokenReportFailed`, holding the tokens
|
|
70
|
+
* An `X::TokenReportFailed` is raised as it is too, from a chunk, the finalize, or a check, so `rescue X::TokenReportFailed` stores its `tokens`
|
|
71
|
+
* Ship this changelog with the gem, linked from the `changelog_uri` of its gemspec
|
|
72
|
+
* Ship a `.yardopts` with the gem, so its documentation on rubydoc.info leaves out the private API
|
|
73
|
+
|
|
74
|
+
### Changed
|
|
75
|
+
* Hold `VERSION` as a String rather than a `Gem::Version`; compare versions with `gem_version`
|
|
76
|
+
* Require Ruby 3.4 or later
|
|
77
|
+
* Accept an IO as well as a path in `upload`, `chunked_upload`, `upload_media`, and the profile image and banner methods
|
|
78
|
+
* A `File` or `Tempfile` is read a chunk at a time, so media of any size is not held in memory
|
|
79
|
+
* Any other IO, such as a `StringIO`, is read and held; one that can seek is read from its start and left where it was
|
|
80
|
+
* A String that holds a NUL byte or a line break, as contents do, raises `ArgumentError`; pass a path or a `StringIO`
|
|
81
|
+
* An IO that cannot be read, such as a closed `StringIO`, raises `X::InvalidMedia`
|
|
82
|
+
* So does media the system refuses to read, such as a socket that is not connected, or a `File` closed while it is read, with its error as the `cause`
|
|
83
|
+
* Type media by the bytes it begins with before its file name
|
|
84
|
+
* Media neither its bytes nor its name types, such as a HEIC photo, raises `X::InvalidMediaType`
|
|
85
|
+
* Pass `media_category:` to upload such media; it was uploaded as an image
|
|
86
|
+
* A file named as a signed type, such as `.png` or `.ts`, that lacks the signature raises `X::InvalidMediaType`
|
|
87
|
+
* Media the category does not take, such as an MP4 with `media_category: "tweet_gif"`, raises `X::InvalidMediaType`
|
|
88
|
+
* Keep `infer_media_type` internal, as are `chunked_upload?`, `infer_media_category`, and `X::Uploads::Gif`
|
|
89
|
+
* Pass `media_type:` to send a type other than the inferred one
|
|
90
|
+
* Send requests to `api.x.com` rather than `api.twitter.com` by default
|
|
91
|
+
* Post profile image and banner uploads with the client given, resolved against its base URL
|
|
92
|
+
* They reach the client's host and reuse its connection, credentials, timeouts, and proxy
|
|
93
|
+
* Raise `X::InvalidMedia`, naming the path, for a file that does not exist, before any request
|
|
94
|
+
* Raise `X::MediaProcessingFailed` instead of `RuntimeError` for media that fails to process
|
|
95
|
+
* `media` holds the status X reported; only the `failed` state is a failure
|
|
96
|
+
* Move the HTTP client into `x-core`, under `lib/x/core`, and the uploaders into `x-uploads`, under `lib/x/uploads`
|
|
97
|
+
* Rename `X::MediaUploader` to `X::Uploads::MediaUpload` and `X::AccountUploader` to `X::Uploads::Account`
|
|
98
|
+
* `X::MediaUploadValidator` became `X::Uploads::Validator`, a private constant
|
|
99
|
+
* Make the internals of the uploaders private constants, so they can change within 1.x
|
|
100
|
+
* The MIME type tables and constants of `X::Uploads::MediaUpload`, such as `MIME_TYPES` and `GIF_MIME_TYPE`
|
|
101
|
+
* `BYTES_PER_MB`, `MAX_SIMPLE_UPLOAD_BYTES`, and the base URL and endpoints of `X::Uploads::Account`
|
|
102
|
+
* The media category constants, such as `TWEET_IMAGE`, remain public
|
|
103
|
+
* The signatures the gem ships declare its public interface alone
|
|
104
|
+
* Upload in chunks of 4 MB, `X::Uploads::MediaUpload::DEFAULT_CHUNK_SIZE`, rather than 1 MB, so a video takes a quarter of the requests
|
|
105
|
+
* `chunk_size:` replaces `chunk_size_mb:`, takes bytes, and defaults to nil, which uploads in chunks of 4 MB
|
|
106
|
+
* Upload a large video with a client whose `max_rate_limit_retries` is set, so a rate limit on a chunk is waited out
|
|
107
|
+
* A file up to the 16 GB the API takes fits its 10,000 segments; a larger one raises `X::InvalidMedia`
|
|
108
|
+
* Upload an animated GIF larger than 5 MB in chunks, which the API takes up to 15 MB of
|
|
109
|
+
* Raise `X::MissingMediaData` instead of `KeyError`, or returning nil, for a response that holds no media or metadata
|
|
110
|
+
* Media without an identifier raises it too, so every `X::UploadedMedia` an upload returns has one
|
|
111
|
+
* Its `problems` hold the problems the response reported, and its message names the first one's detail
|
|
112
|
+
|
|
113
|
+
### Removed
|
|
114
|
+
* Remove `X::MediaUploader.upload_binary`; pass a `StringIO` to `X::Uploads::MediaUpload.upload` instead
|
|
115
|
+
* Remove `upload_profile_image_binary` and `upload_profile_banner_binary`; pass an IO, such as a `StringIO`, instead
|
|
116
|
+
* Remove `X::AccountUploader::MIME_TYPE_MAP`, which nothing read
|
|
117
|
+
* Remove the `boundary:` keyword of the upload methods; each upload generates its own
|
|
118
|
+
* Remove `require "x/media_uploader"` and `require "x/account_uploader"`
|
|
119
|
+
* Require `x`, `x/uploads/media_upload`, or `x/uploads/account` instead
|
|
120
|
+
* Remove `PROCESSING_INFO_STATES`; use `X::UploadedMedia#processing?`
|
|
121
|
+
* Remove `MAX_RETRIES`; a chunk is sent again up to the client's `max_retries`
|
|
122
|
+
|
|
123
|
+
### Fixed
|
|
124
|
+
* Give up waiting for processing after `processing_timeout:` seconds, raising `X::MediaProcessingTimeout`
|
|
125
|
+
* It defaults to 600 seconds; pass `nil` to wait as long as processing takes, as the client's timeouts do
|
|
126
|
+
* `Float::INFINITY`, a negative number, or a non-number raises `ArgumentError` before any request
|
|
127
|
+
* Applies to `upload`, `await_processing(!)`, and the client's `upload_media` and `await_media_processing(!)`
|
|
128
|
+
* Waits at least a second between checks, and as long as X asks, instead of polling in a tight loop
|
|
129
|
+
* Only `succeeded` and `failed` end the wait; any other state, even one X does not document, is still `processing?`
|
|
130
|
+
* Send a chunk or the finalize request again after a server or network error, up to the client's `max_retries`
|
|
131
|
+
* It waits as `Retry-After` asks, up to a minute, or with a randomized backoff, instead of retrying at once
|
|
132
|
+
* A chunk or the finalize request is sent again after a read timeout too, since neither is billed
|
|
133
|
+
* Upload at most `concurrency:` chunks at once, 4 by default, instead of starting a thread per chunk
|
|
134
|
+
* Each chunk is read from the file as it is sent, rather than copied to a temporary file first
|
|
135
|
+
* A chunk that fails stops the chunks not yet begun
|
|
136
|
+
* `x-core` keeps 16 idle connections per host, so each of up to 16 senders keeps its connection between chunks
|
|
137
|
+
* Stop the threads of a chunked upload, and wait for them, when the call is interrupted or times out
|
|
138
|
+
* A thread that cannot be started (`ThreadError`), or an interrupt while starting them, stops those already started
|
|
139
|
+
* A thread storing the tokens of a refresh it made with `save_tokens` finishes storing them first
|
|
140
|
+
* A thread reading a chunk finishes reading it first, so the file is closed rather than left open
|
|
141
|
+
* Validate the `chunk_size:` and `concurrency:` of `chunked_upload` before any request
|
|
142
|
+
* `chunk_size` must be a positive Integer of at most 5,242,880 that fits within the API's 10,000 segments
|
|
143
|
+
* `concurrency` must be an Integer from 1 to 16, `MAX_CONCURRENCY`
|
|
144
|
+
* Anything else, such as a String read from the environment, raises `ArgumentError` instead of `NoMethodError`
|
|
145
|
+
* Validate profile images and banners before a request
|
|
146
|
+
* An empty file, a profile image over 700 KB, or a banner over 5 MB raises `X::InvalidMedia`
|
|
147
|
+
* A file whose bytes are not a GIF, JPEG, or PNG raises `X::InvalidMediaType`, whatever its name
|
|
148
|
+
* A banner `width`, `height`, `offset_left`, or `offset_top` that is not a whole number raises `ArgumentError`
|
|
149
|
+
* Return nil from `update_profile_image` and `update_profile_banner`; look the user up to read the new image
|
|
150
|
+
* Raise `X::InvalidMedia` before any request for an empty file, or media that cannot be read
|
|
151
|
+
* Such as a directory, an unreadable file, or a write-only IO, which raised `Errno::EISDIR`, `EACCES`, or `IOError`
|
|
152
|
+
* Raise `X::InvalidMedia` before any request for an image over 5 MB, a GIF over 15 MB, or subtitles over 1 MB
|
|
153
|
+
* The size X takes of a video depends on the account, so it is left to X
|
|
154
|
+
* Raise `X::InvalidMedia` instead of `TypeError` for a `Pathname` of a file that does not exist
|
|
155
|
+
* Raise `X::MissingMediaData`, not `NoMethodError`, before any chunk when the initialize response holds no media
|
|
156
|
+
* Accept the `amplify_video` media category, uploading it in chunks and awaiting its processing
|
|
157
|
+
* Upload WebM, QuickTime, and MPEG-TS videos, WebVTT subtitles, and BMP, TIFF, and progressive JPEG images
|
|
158
|
+
* Each is sent as its own type, where every video was sent as MP4 and all subtitles as SubRip
|
|
159
|
+
* Upload an `.m4v` file as an MP4 video, in chunks, rather than as an image
|
|
160
|
+
* Raise `X::InvalidMediaType` before any request for `.avi`, `.mkv`, `.glb`, and `.usdz` files
|
|
161
|
+
* An `.avi` or `.mkv` file that begins with the signature of a type the API documents uploads as that type, as WebM does; a `.glb` or `.usdz` file is refused whatever it holds
|
|
162
|
+
* Upload subtitles in chunks as `text/srt`, the type the API names, instead of as `application/x-subrip` in one request
|
|
163
|
+
* Send the media category in lowercase, as the API documents it, whatever case it was given in
|
|
164
|
+
* Read the size of chunked media once, so a file that grows during the upload sends the `total_bytes` it declared
|
|
165
|
+
* Parse upload responses into Hashes and Arrays whatever the client's `default_object_class` and `default_array_class`
|
|
166
|
+
* Give a class that includes `X::Uploads::MediaUpload` or `X::Uploads::Account` their public methods alone
|
|
167
|
+
* Helpers such as `init`, `append`, and `media_id` are not mixed in, so a method of one of those names cannot break it
|
|
168
|
+
* Declare `json` in `sig/manifest.yaml`, so `rbs collection` loads it for code that depends on `x-uploads`
|
|
169
|
+
* Link `changelog_uri` to the `main` branch rather than `master`
|
|
170
|
+
|
|
171
|
+
[1.0.0]: https://github.com/sferik/x-ruby/releases/tag/v1.0.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Erik Berlin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# x-uploads
|
|
2
|
+
|
|
3
|
+
Media uploads for the [`x` gem](https://github.com/sferik/x-ruby), built on the HTTP client in [`x-core`](https://github.com/sferik/x-ruby/tree/main/x-core).
|
|
4
|
+
|
|
5
|
+
* `X::Uploads::MediaUpload` uploads images, GIFs, videos, and subtitles. Videos, subtitles, and a GIF larger than a single request takes are split into chunks that upload in parallel, with retries, and processing can be awaited.
|
|
6
|
+
* `X::Uploads::Account` updates the authenticated user's profile image and banner through the v1.1 API, at the host of the base URL of the client it is given.
|
|
7
|
+
|
|
8
|
+
Installing [`x`](https://rubygems.org/gems/x) installs this gem too.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
`x-uploads` requires Ruby 3.4 or later.
|
|
13
|
+
|
|
14
|
+
bundle add x-uploads
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
require "x/uploads"
|
|
20
|
+
|
|
21
|
+
client = X::Client.new(**x_credentials)
|
|
22
|
+
|
|
23
|
+
media = X::Uploads::MediaUpload.upload("cat.jpg", client:, alt_text: "A cat asleep on a keyboard")
|
|
24
|
+
|
|
25
|
+
video = X::Uploads::MediaUpload.chunked_upload("cat.mp4", client:)
|
|
26
|
+
X::Uploads::MediaUpload.await_processing!(video, client:)
|
|
27
|
+
|
|
28
|
+
subtitles = X::Uploads::MediaUpload.upload("cat.srt", client:)
|
|
29
|
+
X::Uploads::Metadata.add_subtitles(video, subtitles, "EN", client:, display_name: "English")
|
|
30
|
+
|
|
31
|
+
X::Uploads::Account.update_profile_image("avatar.png", client:)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The `client:` of each uploader is an `X::Client` of `x-core`, which sends the multipart and JSON bodies of an upload, resolves the v1.1 endpoints of the profile uploads against its base URL, and sends a chunk, the finalize, and alt text or subtitles again after a server error, 408, or network error, even a timeout, up to its `max_retries`, unless a `Retry-After` asks for more than a minute, though it sends no other POST again; X bills each alt text or subtitles request, so one whose answer was lost may be billed twice.
|
|
35
|
+
|
|
36
|
+
An upload takes media as a path, or as an IO open on it. Media given as a `String` or a `Pathname` is read from the file it names, and media given as a `File` or a `Tempfile`, or as `$stdin` redirected from a file, through that IO, from the start of the file, even once the `Tempfile` is unlinked, or from the file it names once it is closed, each a chunk at a time, so media of any size uploads without being held in memory; media given as any other IO, such as a `StringIO`, or `$stdin` reading a pipe, is read to its end and held. Either IO is left at the position it held, when it can seek, so media whose type was inferred can be uploaded next. The media category is inferred from the bytes the media begins with, which name a GIF, PNG, JPEG, BMP, TIFF, WebP, MP4, QuickTime, WebM, MPEG transport stream, or WebVTT file, or else from the extension of the name of its file, so a file named as what it is not, such as a PNG named `.gif`, is uploaded as what it is. A file named as a type every file of which begins with a signature, a GIF, PNG, JPEG, BMP, TIFF, WebP, WebVTT, or MPEG transport stream file, that does not begin with it, such as TypeScript named `.ts`, raises `X::InvalidMediaType` before any request. A String is a path, so one that holds the contents of media, a NUL byte or a line break, raises `ArgumentError` before any request: pass a `StringIO` of the contents instead. Media whose type neither its bytes nor the name of its file names, such as SubRip subtitles held in a `StringIO`, a HEIC photo, a PDF, or a `Tempfile` of any of them, raises `X::InvalidMediaType` before any request unless `media_category:` says what it is.
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
X::Uploads::MediaUpload.upload(Pathname("cat.jpg"), client:)
|
|
40
|
+
File.open("cat.mp4", "rb") { |file| X::Uploads::MediaUpload.upload(file, client:) }
|
|
41
|
+
X::Uploads::MediaUpload.upload(StringIO.new(png), client:)
|
|
42
|
+
X::Uploads::MediaUpload.upload(StringIO.new(srt), client:, media_category: "subtitles")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
An upload returns an `X::UploadedMedia`, a frozen object that holds the response, or the processing status of media that X processes. It reads `id`, as an Integer, though `media["id"]` is the String the API gave, which must be an `Integer` or a `String` of 1 to 19 digits alone, with no sign, underscore, or whitespace, so `X::UploadedMedia.new` raises `ArgumentError` for any other, `media_id`, the same Integer, as the `X::Media` of `x-resources` reads it, `media_key`, `bytesize`, `expires_after_secs`, the seconds after the upload within which a post can attach it, and `state`, tells `processing?`, until X reports that its processing succeeded or failed, `failed?`, and `ready?`, once its processing has succeeded or for media X does not process, so media in a state X does not document, as one X adds would be, is still processing, and is waited for as pending media is, up to the `processing_timeout:`, while media built from an identifier alone, which holds no response of X to say, is neither processing nor ready, and still reads as the Hash an upload used to return, with `[]`, `fetch`, `dig`, `key?`, `to_h`, and `to_json`. The uploaders take it wherever they take media, as `create_post` of `x-resources` does, and `find_media(media)` of `x-resources` looks up the `X::Media` it became, with its URL and variants.
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
media.id # => 1880028106020515840
|
|
49
|
+
media["id"] # => "1880028106020515840", the String the API gave, which media.id reads as an Integer
|
|
50
|
+
media.ready? # => true once X reports its processing succeeded, or at once for an image
|
|
51
|
+
media.expires_after_secs # => 86400, the seconds after the upload within which a post can attach it
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`X::Uploads::API` holds the uploads a client is most often asked for, as methods that call the uploaders with the client. The `x` gem includes it into `X::Client`. With `x-core` and `x-uploads` alone, include it yourself:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
X::Client.include(X::Uploads::API)
|
|
58
|
+
|
|
59
|
+
media = client.upload_media("cat.jpg", alt_text: "A cat asleep on a keyboard")
|
|
60
|
+
video = client.chunked_upload_media("talk.mp4") # returns once uploaded, before X processes it
|
|
61
|
+
client.upload_media(StringIO.new(png)) # media held in memory, whose category its signature names
|
|
62
|
+
client.await_media_processing(video) # returns the status, failed or not, without a request once processing ended
|
|
63
|
+
client.await_media_processing!(video) # raises X::MediaProcessingFailed unless processing succeeded
|
|
64
|
+
client.add_alt_text(media, "A cat asleep on a keyboard") # returns the media, an X::UploadedMedia
|
|
65
|
+
client.add_subtitles(video, subtitles, "EN", display_name: "English")
|
|
66
|
+
client.update_profile_image("avatar.png")
|
|
67
|
+
client.update_profile_banner("banner.png", width: 1500, height: 500)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A type checker needs the include declared too, as the `sig/x.rbs` of `x` declares it, with `class X::Client` and `include X::Uploads::API` in a signature of your own.
|
|
71
|
+
|
|
72
|
+
Every method that takes a file takes its path as a `String` or a `Pathname`, or an IO that reads it, such as a `File` or a `StringIO`, which is read from its start; each raises `X::InvalidMedia` for a path to a file that does not exist, before any request, as it does for media that cannot be read, such as an IO whose `read` raises a system error, or a `File` closed while it is read, as by another thread, either of which is its `cause`, and for any media the API would refuse. A `String` is read as a path on the machine the upload runs on, so never pass one a user gave, such as a parameter of a form, which could name any file the process can read, and upload it to X: pass the IO of the file the user uploaded, such as the `tempfile` of a Rack upload, instead. A profile image or banner must begin with the signature of a GIF, a JPEG, or a PNG, whatever its file is named, and a profile image larger than the 700 KB the API takes, or a banner larger than the 5 MB X takes, raises `X::InvalidMedia` before any request. The `width:` and `height:` of the region of a banner to use are positive `Integer`s of pixels, and its `offset_left:` and `offset_top:` `Integer`s of at least 0, or nil, and anything else raises `ArgumentError` before any request. A media category is read in any case, as a `String` or a `Symbol`, and alt text of more than the 1,000 characters the API takes, or of none, raises `ArgumentError` before anything is uploaded. `await_processing` and `await_processing!` take the response of an upload, a media identifier, or anything that answers `media_key`, such as the `X::Media` of a post, as `X::Uploads::Metadata` does, and raise `ArgumentError` for anything else, and for media that holds no identifier, such as nil. `add_alt_text` and `add_subtitles` return the media they describe, the video for `add_subtitles`, as an `X::UploadedMedia`. An image larger than 5 MB, a GIF larger than 15 MB, subtitles larger than 1 MB, and media larger than the 16 GB X takes of any upload raise `X::InvalidMedia` before any request, since X takes no more of them whatever the account, as does media that cannot be read or holds nothing; the size of a video within that depends on the account, so it is left to X. `X::InvalidMedia` is raised for the media, which a user may have given, and `ArgumentError` for a mistake in the arguments of the call, so code that uploads what a user gives rescues the one alone, though an `X::InvalidMedia` whose `cause` is a system error, such as `Errno::EMFILE` or `Errno::EIO`, says the machine could not read the media, not that the media is wrong.
|
|
73
|
+
|
|
74
|
+
`upload` infers the media category from the file. A GIF with a single frame is an image, because X processes only animated GIFs as GIFs. Videos are MP4, QuickTime, WebM, or MPEG-TS files and subtitles are SubRip (`.srt`) or WebVTT (`.vtt`) files, each uploaded in chunks as the type its bytes, or else its extension, name. Media of a type its `media_category:` does not take, such as an MP4 video uploaded as `tweet_gif` or `subtitles`, or a PNG uploaded as `tweet_video`, raises `X::InvalidMediaType` before any request, rather than be sent as a type it is not; an upload in chunks, by `chunked_upload` or by `upload` of a category that uploads in chunks, such as `tweet_video`, sends the `media_type:` it is given as it is, without these checks, while an upload in a single request sends no type, since the API types the media itself. A video or subtitles category uploads media whose type neither its bytes nor its name names as MP4 or SubRip, which may begin with nothing that names them, and an image category sends it in a single request for the API to type, as it types a HEIC photo, or, uploaded in chunks, as shared media is, as JPEG. An `.m4v` file is MP4. The API documents no media type for AVI or Matroska video, so an `.avi` or `.mkv` file, or media whose name names no type, such as a `StringIO`, that begins with the header of Matroska, raises `X::InvalidMediaType` before any request, unless it begins with the signature of a type the API documents, as a WebM video, which is Matroska and uploads as WebM, does; convert any other to MP4, QuickTime, WebM, or MPEG-TS. No media category takes a 3D model, so a `.glb` or `.usdz` file raises `X::InvalidMediaType` before any request too. Any of these uploads in chunks as the `media_type:` it is given along with a `media_category:`, which together name the type to send for media no rule here types; a `media_type:` alone is not enough, since the category is inferred from the media first.
|
|
75
|
+
|
|
76
|
+
`X::AltTextFailed`, which an upload raises when it uploaded the media but could not add its alt text, and which holds the `media` it uploaded, `X::ChunkedUploadFailed`, which a chunked upload raises when it initialized the upload but a chunk or its finalize failed, and which holds the `media` it initialized, `X::InvalidMedia`, which a file that does not exist, and media the API would refuse, raise before any request, and which `X::InvalidMediaType` descends from, `X::MediaProcessingCheckFailed`, which an upload raises when it uploaded the media but a check of its processing failed, and which holds the `media` it uploaded too, `X::MediaProcessingFailed` and `X::MediaProcessingTimeout`, which hold the `media` as X last reported its processing, and `X::MissingMediaData`, which a response that succeeded without the media or metadata it should describe raises, and which holds the `problems` the response reported in their place, descend from `X::Uploads::Error`, which descends from `X::Error`, so `rescue X::Uploads::Error` catches the errors x-uploads raises of its own. An `X::TokenReportFailed`, which a request that refreshed the tokens raises when `save_tokens` raised for them, is raised as it is from any request of an upload, a chunk, the finalize, a check of processing, or alt text among them, rather than as the cause of one of these, so `rescue X::TokenReportFailed => e` around an upload stores `e.tokens`, as it does around any other request, though it holds no media. An `X::Error` of a request the API refused, or that got no response, before there was media to hold, such as the `X::BadRequest` of the request that initializes an upload, raises past it, so `rescue X::Error` catches every failure of an upload but an `ArgumentError`, which is not an `X::Error`: that of a mistake in the arguments of a call, and the one a request raises when the client was given no `proxy_url` and the proxy the environment names, in `https_proxy` or `http_proxy`, in either case, cannot be parsed, or is not an `http` or `https` URL with a host, with a message that names the variables read and leaves out the value. The request that initializes an upload, or sends it in a single request, raises that one as it is, and a later request that opens a connection fails with it as the `cause` of the `X::ChunkedUploadFailed`, `X::MediaProcessingCheckFailed`, or `X::AltTextFailed` it raises.
|
|
77
|
+
|
|
78
|
+
`upload` uploads an image, and a GIF a single request takes, in a single request, which takes no chunks and no media type, so it ignores `chunk_size:`, `concurrency:`, and `media_type:` for them, though a `chunk_size:` or `concurrency:` that is not valid still raises; media given `shared: true` uploads in chunks, and uses all three. `chunked_upload`, and `chunked_upload_media` of a client, upload in chunks whatever the media, and are the way to upload without waiting for X to process it: `upload` waits for the processing of a video or an animated GIF, and they return once the upload is finalized.
|
|
79
|
+
|
|
80
|
+
A chunked upload sends four chunks at once, `X::Uploads::MediaUpload::DEFAULT_CONCURRENCY`, unless `concurrency:` says otherwise, up to 16, `X::Uploads::MediaUpload::MAX_CONCURRENCY`, since each chunk sent at once holds up to 5 MB and a connection of its own; a concurrency above that raises `ArgumentError` before anything is uploaded. It uploads in chunks of 4 MB, `X::Uploads::MediaUpload::DEFAULT_CHUNK_SIZE`, or of as much more as it takes to upload the file in the 10,000 segments the API numbers, up to the 5 MB X asks a segment to keep to, unless `chunk_size:` says otherwise, in bytes; a chunk size that is not a positive `Integer`, one above the 5,242,880 bytes of 5 MB, and one that would need more segments than that raise `ArgumentError` before anything is uploaded, as a file larger than the 16 GB X takes of an upload raises `X::InvalidMedia`. Each chunk is a request of its own, which a rate limit can refuse, and a client retries a request refused for a rate limit only `max_rate_limit_retries` times, 0 by default, so a chunk refused fails the upload with `X::ChunkedUploadFailed`: upload a large video with a client whose `max_rate_limit_retries` is set, such as `X::Client.new(**x_credentials, max_rate_limit_retries: 3)`, so that a rate limit is waited out, up to its `max_rate_limit_wait`, rather than fail the upload. An exception raised in the thread that uploads, such as a timeout or an interrupt, stops every chunk, so no thread goes on uploading after the call has ended, though a chunk storing the tokens of a refresh with `save_tokens` finishes storing them first, as one reading its bytes from the media finishes reading them, so that the file is closed, and one under way when an exception a `trap` raises lands may finish its upload. Since each chunk is sent from a thread of its own, the client's `on_response` runs on those threads for the response of each chunk, so a hook that reads state kept for the thread that called, such as a Rails `CurrentAttributes` or a logger of its own, reads that of another thread while the chunks upload.
|
|
81
|
+
|
|
82
|
+
An upload, in chunks or not, takes `additional_owners:`, the identifiers of the users other than the one who uploads it who may use the media, as `Integer`s or `String`s of digits, and `shared: true` or `false`, which says whether the media can be sent in more than one direct message. Media given `shared: true` uploads in chunks, since the single request an image is uploaded in takes no `shared`. Either that is not what the API takes raises `ArgumentError` before anything is uploaded.
|
|
83
|
+
|
|
84
|
+
The methods of `X::Uploads::MediaUpload`, `X::Uploads::Account`, and `X::Uploads::Metadata` can be called on the module, or on an instance of a class that includes it. Such a class gains the documented public methods and constants alone, the constants being those of `X::Uploads::MediaUpload`, which are its media categories, such as `TWEET_IMAGE`, and `DEFAULT_CHUNK_SIZE`, `DEFAULT_CONCURRENCY`, `DEFAULT_PROCESSING_TIMEOUT`, and `MAX_CONCURRENCY`, so its own methods, whatever their names, cannot change an upload.
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
This gem has its own `Gemfile`, `Steepfile`, signatures, test suite, and mutation config. It uses the `x-core` in this repository and does not load `x-resources`:
|
|
89
|
+
|
|
90
|
+
bundle install
|
|
91
|
+
bundle exec rake test
|
|
92
|
+
bundle exec rake mutant
|
|
93
|
+
bundle exec rake steep
|
|
94
|
+
bundle exec rake yardstick
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "x/core"
|
|
4
|
+
require_relative "invalid_media_type"
|
|
5
|
+
require_relative "multipart"
|
|
6
|
+
require_relative "source"
|
|
7
|
+
require_relative "validator"
|
|
8
|
+
|
|
9
|
+
module X
|
|
10
|
+
module Uploads
|
|
11
|
+
# Uploads profile images and banners to the X API v1.1
|
|
12
|
+
#
|
|
13
|
+
# Its methods can be called on the module, or on an instance of a class that includes it, which gains its public
|
|
14
|
+
# methods alone. They post to the API v1.1 endpoint with the client they are given, which keeps the connection it
|
|
15
|
+
# holds to the host open for the requests that follow, rather than with a copy of it. The endpoint is resolved
|
|
16
|
+
# against the base URL of the client, in place of the version it names, so it is reached at the host the client
|
|
17
|
+
# sends its other requests to, which its credentials are sent to, whether that is api.x.com, api.twitter.com,
|
|
18
|
+
# or a proxy of the API.
|
|
19
|
+
#
|
|
20
|
+
# @api public
|
|
21
|
+
module Account
|
|
22
|
+
extend self
|
|
23
|
+
|
|
24
|
+
# The API v1.1, relative to the base URL of a client, which names the version of the API it requests
|
|
25
|
+
V1_BASE_URL = "../1.1/"
|
|
26
|
+
# The endpoint that updates the profile image of the authenticating user, relative to the base URL of a client
|
|
27
|
+
PROFILE_IMAGE_URL = "#{V1_BASE_URL}account/update_profile_image.json".freeze
|
|
28
|
+
# The endpoint that updates the profile banner of the authenticating user, relative to the base URL of a client
|
|
29
|
+
PROFILE_BANNER_URL = "#{V1_BASE_URL}account/update_profile_banner.json".freeze
|
|
30
|
+
private_constant :V1_BASE_URL, :PROFILE_IMAGE_URL, :PROFILE_BANNER_URL
|
|
31
|
+
|
|
32
|
+
# Update the authenticating user's profile image
|
|
33
|
+
#
|
|
34
|
+
# It returns nil, whatever the client answers with, as {update_profile_banner} does: the API v1.1 answers with
|
|
35
|
+
# the user in its own shape, keyed as v1.1 keys it, which no object of these gems reads, since an X::User of
|
|
36
|
+
# x-resources reads the users of the API v2 alone. Look the user up with the API v2 to read the image it now has.
|
|
37
|
+
#
|
|
38
|
+
# @api public
|
|
39
|
+
# @param media [String, Pathname, IO, StringIO] the path to the image, or an IO that reads it, which is read from
|
|
40
|
+
# its start, as the media of an upload is
|
|
41
|
+
# @param client [Client] the X API client
|
|
42
|
+
# @return [void]
|
|
43
|
+
# @raise [InvalidMedia] if the file does not exist
|
|
44
|
+
# @raise [ArgumentError] if the media is neither a path nor an IO
|
|
45
|
+
# @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
|
|
46
|
+
# cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read and
|
|
47
|
+
# leaves out the value
|
|
48
|
+
# @raise [InvalidMedia] if the media cannot be read, is empty, which holds nothing to upload, or is larger than
|
|
49
|
+
# the 700 kilobytes the API takes of a profile image
|
|
50
|
+
# @raise [InvalidMediaType] if the media does not begin with the signature of a GIF, a JPEG, or a PNG, whatever
|
|
51
|
+
# its file is named
|
|
52
|
+
# @example Update profile image from a file
|
|
53
|
+
# Uploads::Account.update_profile_image("avatar.png", client: client)
|
|
54
|
+
# @example Update profile image from an image held in memory
|
|
55
|
+
# Uploads::Account.update_profile_image(StringIO.new(png), client: client)
|
|
56
|
+
def update_profile_image(media, client:)
|
|
57
|
+
source = Source.for(media)
|
|
58
|
+
Validator.validate_profile_image!(source, Validator::MAX_PROFILE_IMAGE_BYTES, "a profile image")
|
|
59
|
+
Multipart.post(client, PROFILE_IMAGE_URL, "image", source.content)
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Update the authenticating user's profile banner
|
|
64
|
+
#
|
|
65
|
+
# It returns nil, whatever the client answers with, since the endpoint answers with no content once the banner
|
|
66
|
+
# is updated.
|
|
67
|
+
#
|
|
68
|
+
# @api public
|
|
69
|
+
# @param media [String, Pathname, IO, StringIO] the path to the image, or an IO that reads it, which is read from
|
|
70
|
+
# its start, as the media of an upload is
|
|
71
|
+
# @param client [Client] the X API client
|
|
72
|
+
# @param width [Integer, nil] the width of the region of the image to use, in pixels, of at least 1
|
|
73
|
+
# @param height [Integer, nil] the height of the region of the image to use, in pixels, of at least 1
|
|
74
|
+
# @param offset_left [Integer, nil] the pixels by which the region is offset from the left, of at least 0
|
|
75
|
+
# @param offset_top [Integer, nil] the pixels by which the region is offset from the top, of at least 0
|
|
76
|
+
# @return [void]
|
|
77
|
+
# @raise [InvalidMedia] if the file does not exist
|
|
78
|
+
# @raise [ArgumentError] if the media is neither a path nor an IO, or a width, height, or offset is neither
|
|
79
|
+
# nil nor an Integer of the pixels it takes
|
|
80
|
+
# @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
|
|
81
|
+
# cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read and
|
|
82
|
+
# leaves out the value
|
|
83
|
+
# @raise [InvalidMedia] if the media cannot be read, is empty, which holds nothing to upload, or is larger than
|
|
84
|
+
# the 5 megabytes X takes of a profile banner
|
|
85
|
+
# @raise [InvalidMediaType] if the media does not begin with the signature of a GIF, a JPEG, or a PNG, whatever
|
|
86
|
+
# its file is named
|
|
87
|
+
# @example Update profile banner from a file
|
|
88
|
+
# Uploads::Account.update_profile_banner("banner.png", client: client)
|
|
89
|
+
# @example Update profile banner with dimensions
|
|
90
|
+
# Uploads::Account.update_profile_banner("banner.png", client: client, width: 1500, height: 500)
|
|
91
|
+
def update_profile_banner(media, client:, width: nil, height: nil, offset_left: nil, offset_top: nil)
|
|
92
|
+
Validator.validate_banner_region!(width:, height:, offset_left:, offset_top:)
|
|
93
|
+
source = Source.for(media)
|
|
94
|
+
Validator.validate_profile_image!(source, Validator::MAX_PROFILE_BANNER_BYTES, "a profile banner")
|
|
95
|
+
Multipart.post(client, PROFILE_BANNER_URL, "banner", source.content, width:, height:, offset_left:, offset_top:)
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
require_relative "error"
|
|
5
|
+
require_relative "uploaded_media"
|
|
6
|
+
|
|
7
|
+
module X
|
|
8
|
+
# Error raised when media uploaded with alt text is uploaded, but its alt text cannot be added
|
|
9
|
+
#
|
|
10
|
+
# The alt text is added once the media is uploaded, and processed, so the media uploaded is not lost to
|
|
11
|
+
# a failure to add it, whether the API refuses it or the on_response hook of the client raises: the error holds the
|
|
12
|
+
# media, which can be attached to a post, or given its alt text again with add_alt_text. The error that failed to
|
|
13
|
+
# add it is the cause, whose message the message ends with. A TokenReportFailed, which the request raises when
|
|
14
|
+
# save_tokens raised for the tokens of a refresh it made, is raised as it is, rather than as this error, so that the
|
|
15
|
+
# rescue of it that stores the tokens it holds catches it around an upload as around any other request.
|
|
16
|
+
#
|
|
17
|
+
# @api public
|
|
18
|
+
class AltTextFailed < Uploads::Error
|
|
19
|
+
# Add alt text to uploaded media, raising this error, which holds it, on failure
|
|
20
|
+
#
|
|
21
|
+
# Internal to x-uploads: an upload adds the alt text it is given through it, and calls it with __send__, since it
|
|
22
|
+
# is private. A Timeout::Error, as Timeout.timeout raises around the upload, a TokenReportFailed, which holds the
|
|
23
|
+
# tokens of a refresh that save_tokens raised for, and an exception that is not a StandardError, such as an
|
|
24
|
+
# Interrupt, are raised as they are.
|
|
25
|
+
#
|
|
26
|
+
# @api private
|
|
27
|
+
# @param media [UploadedMedia] the uploaded media
|
|
28
|
+
# @yield adds the alt text
|
|
29
|
+
# @return [Object] what the block returns
|
|
30
|
+
# @raise [AltTextFailed] if the block raises a StandardError other than a Timeout::Error or a TokenReportFailed,
|
|
31
|
+
# such as an error of the X API
|
|
32
|
+
# @raise [TokenReportFailed] if save_tokens raises for the tokens of a refresh the block made, with the tokens
|
|
33
|
+
# @example Add alt text to an upload, keeping the media if it cannot be added
|
|
34
|
+
# X::AltTextFailed.__send__(:keeping, media) { Uploads::Metadata.add_alt_text(media, "A cat", client:) }
|
|
35
|
+
def self.keeping(media)
|
|
36
|
+
yield
|
|
37
|
+
rescue Timeout::Error, TokenReportFailed
|
|
38
|
+
raise
|
|
39
|
+
rescue
|
|
40
|
+
raise new(media:)
|
|
41
|
+
end
|
|
42
|
+
private_class_method :keeping
|
|
43
|
+
|
|
44
|
+
# The media that was uploaded, without its alt text
|
|
45
|
+
# @api public
|
|
46
|
+
# @return [UploadedMedia, nil] the uploaded media, or nil if none was given
|
|
47
|
+
# @example Add the alt text again later
|
|
48
|
+
# rescue X::AltTextFailed => e
|
|
49
|
+
# client.add_alt_text(e.media, "A cat asleep on a keyboard")
|
|
50
|
+
attr_reader :media
|
|
51
|
+
|
|
52
|
+
# Initialize the error with the media that was uploaded
|
|
53
|
+
#
|
|
54
|
+
# The message is the one given, or else names the media by its identifier, when media that holds one was given.
|
|
55
|
+
#
|
|
56
|
+
# @api public
|
|
57
|
+
# @param message [String, nil] the message, or nil for one that names the media
|
|
58
|
+
# @param media [UploadedMedia, Hash{String => Object}, nil] the media that was uploaded, a Hash of which is held as
|
|
59
|
+
# uploaded media
|
|
60
|
+
# @return [AltTextFailed] a new error
|
|
61
|
+
# @example Raise the error for media whose alt text could not be added
|
|
62
|
+
# raise X::AltTextFailed.new(media: media)
|
|
63
|
+
# @example Raise the error with a message of its own, as a test stub may
|
|
64
|
+
# raise X::AltTextFailed, "Alt text could not be added"
|
|
65
|
+
def initialize(message = nil, media: nil)
|
|
66
|
+
@media = media.is_a?(Hash) ? UploadedMedia.new(media) : media
|
|
67
|
+
super(message || ["Media", media&.[]("id"), "was uploaded, but its alt text could not be added"].compact.join(" "))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The message, ending with why the alt text could not be added
|
|
71
|
+
#
|
|
72
|
+
# It ends with the message of the error that failed to add the alt text, which is the cause, if there is one,
|
|
73
|
+
# whether the message was given or named the media.
|
|
74
|
+
#
|
|
75
|
+
# @api public
|
|
76
|
+
# @return [String] the message
|
|
77
|
+
# @example Read why the alt text could not be added
|
|
78
|
+
# error.message # => "Media 7 was uploaded, but its alt text could not be added: Bad Request"
|
|
79
|
+
def to_s = [super, cause&.message].compact.join(": ")
|
|
80
|
+
end
|
|
81
|
+
end
|