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 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
@@ -0,0 +1,8 @@
1
+ --markup markdown
2
+ --readme README.md
3
+ --hide-api private
4
+ --embed-mixins
5
+ lib/**/*.rb
6
+ -
7
+ CHANGELOG.md
8
+ LICENSE.txt
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