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
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "invalid_media"
|
|
4
|
+
require_relative "invalid_media_type"
|
|
5
|
+
require_relative "signature"
|
|
6
|
+
require_relative "source"
|
|
7
|
+
|
|
8
|
+
module X
|
|
9
|
+
module Uploads
|
|
10
|
+
# Validates media upload parameters
|
|
11
|
+
#
|
|
12
|
+
# Internal to x-uploads: the uploaders validate their arguments with it.
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
15
|
+
module Validator
|
|
16
|
+
extend self
|
|
17
|
+
|
|
18
|
+
# Number of bytes per megabyte
|
|
19
|
+
BYTES_PER_MB = 1_048_576
|
|
20
|
+
# Greatest number of characters of alt text the API takes
|
|
21
|
+
MAX_ALT_TEXT_LENGTH = 1000
|
|
22
|
+
# The pattern of the language code of subtitles, two letters, which the API takes in upper case
|
|
23
|
+
LANGUAGE_CODE = /\A[a-z]{2}\z/i
|
|
24
|
+
# The identifier of a user, as the API takes it among the additional owners of media
|
|
25
|
+
USER_ID = /\A[0-9]{1,19}\z/
|
|
26
|
+
# Greatest number of segments an upload in chunks can have: the OpenAPI specification of the API v2 takes a
|
|
27
|
+
# segment_index of 0 to 9999, so an upload in more chunks than these would fail partway, once the media uploaded
|
|
28
|
+
# so far had been billed. Chunks of DEFAULT_CHUNK bytes upload 41 GB of media in them, more than the
|
|
29
|
+
# MAX_UPLOAD_BYTES the API takes of any upload
|
|
30
|
+
MAX_SEGMENTS = 10_000
|
|
31
|
+
# Greatest number of bytes in a segment of an upload in chunks: the guide to chunked uploads says to keep each
|
|
32
|
+
# segment at or below 5 MB, of the 8 MB the server takes at most, so a chunk is 5 megabytes at most, which is
|
|
33
|
+
# below 8 MB whether a megabyte is read as 1,000,000 bytes or as 1,048,576
|
|
34
|
+
MAX_CHUNK = 5 * BYTES_PER_MB
|
|
35
|
+
# Number of bytes in a chunk of an upload given no chunk size, unless the media needs larger ones to fit the
|
|
36
|
+
# MAX_SEGMENTS the API numbers: each chunk is a request, any of which a rate limit can refuse, which fails the
|
|
37
|
+
# upload unless the client waits it out, so a chunk is 4 megabytes, within the MAX_CHUNK of a segment, which
|
|
38
|
+
# sends a quarter of the requests chunks of a megabyte did
|
|
39
|
+
DEFAULT_CHUNK = 4 * BYTES_PER_MB
|
|
40
|
+
# Greatest number of chunks uploaded at once: each holds a chunk of up to MAX_CHUNK bytes in memory and a
|
|
41
|
+
# connection of its own, so 16 hold 80 megabytes on the 16 connections a client keeps open to a host, and more
|
|
42
|
+
# would hold more of both than an upload gains from, since the connections the client does not keep are opened
|
|
43
|
+
# again for each chunk
|
|
44
|
+
MAX_CONCURRENCY = 16
|
|
45
|
+
# Greatest number of bytes the API takes in a single upload request, above which an animated GIF, which it
|
|
46
|
+
# takes in chunks of up to 15 MB, uploads in chunks
|
|
47
|
+
MAX_SIMPLE_UPLOAD_BYTES = 5 * BYTES_PER_MB
|
|
48
|
+
# The media types of the images a profile image or banner takes
|
|
49
|
+
PROFILE_IMAGE_TYPES = %w[image/gif image/jpeg image/png].freeze
|
|
50
|
+
# The least number of pixels each dimension of the region of a profile banner takes: a width and a height hold at
|
|
51
|
+
# least one, and an offset none
|
|
52
|
+
BANNER_REGION = {width: 1, height: 1, offset_left: 0, offset_top: 0}.freeze
|
|
53
|
+
# Greatest number of bytes the API v1.1 takes of a profile image: its reference for update_profile_image takes an
|
|
54
|
+
# image of less than 700 kilobytes, each of 1,024 bytes, as a megabyte here is 1,048,576
|
|
55
|
+
MAX_PROFILE_IMAGE_BYTES = 700 * 1024
|
|
56
|
+
# Greatest number of bytes X takes of a profile banner: the reference of the API v1.1 for update_profile_banner
|
|
57
|
+
# names none, and answers a banner too large with 422, and the help of X gives 5 MB for a header photo
|
|
58
|
+
MAX_PROFILE_BANNER_BYTES = 5 * BYTES_PER_MB
|
|
59
|
+
# Valid media category values
|
|
60
|
+
MEDIA_CATEGORIES = %w[amplify_video dm_gif dm_image dm_video subtitles tweet_gif tweet_image tweet_video].map(&:freeze).freeze
|
|
61
|
+
# Greatest number of bytes the API takes of media of each category that documents a size of its own for every
|
|
62
|
+
# account: the guides to media on docs.x.com give 5 MB for an image and 15 MB for a GIF, whether or not the
|
|
63
|
+
# user has X Premium, and 1 MB for subtitles, and the API has refused an image of more than 5,242,880 bytes,
|
|
64
|
+
# so a megabyte is 1,048,576 bytes. The size of a video depends on the account, so none is checked but the
|
|
65
|
+
# MAX_UPLOAD_BYTES of any upload.
|
|
66
|
+
MAX_MEDIA_BYTES = {
|
|
67
|
+
"dm_image" => 5 * BYTES_PER_MB, "tweet_image" => 5 * BYTES_PER_MB, "dm_gif" => 15 * BYTES_PER_MB,
|
|
68
|
+
"tweet_gif" => 15 * BYTES_PER_MB, "subtitles" => BYTES_PER_MB
|
|
69
|
+
}.freeze
|
|
70
|
+
# Greatest number of bytes the API takes of any upload: the OpenAPI specification of the API v2 takes a
|
|
71
|
+
# total_bytes of at most 17,179,869,184, 16 gigabytes of 1,073,741,824 bytes, to initialize an upload with
|
|
72
|
+
MAX_UPLOAD_BYTES = 16 * 1024 * BYTES_PER_MB
|
|
73
|
+
|
|
74
|
+
# Validate the arguments of an upload, and give its media category in lowercase
|
|
75
|
+
#
|
|
76
|
+
# It validates everything an upload can be refused for before it sends a request, so that no media is uploaded,
|
|
77
|
+
# and billed, for an upload that cannot finish. A media category of nil is inferred with the block, once the
|
|
78
|
+
# media is known to exist and hold something, so that media that holds nothing raises for that, rather than for
|
|
79
|
+
# a category nothing names.
|
|
80
|
+
#
|
|
81
|
+
# @api private
|
|
82
|
+
# @param source [Source] the media to upload
|
|
83
|
+
# @param media_category [String, Symbol, nil] the media category, in any case, or nil to infer it
|
|
84
|
+
# @param alt_text [String, nil] the alt text of the media, or nil for media described with none
|
|
85
|
+
# @param chunk_size [Integer, nil] the size of each chunk in bytes, or nil to derive one
|
|
86
|
+
# @param concurrency [Integer] the number of chunks uploaded at once, of 1 to MAX_CONCURRENCY
|
|
87
|
+
# @param processing_timeout [Integer, Float] the seconds to wait for the media to process
|
|
88
|
+
# @param shared [Boolean, nil] whether the media is shared, or nil to leave it to the API
|
|
89
|
+
# @param additional_owners [Array<Integer, String>, nil] the identifiers of the users who may use the media, or
|
|
90
|
+
# nil for none
|
|
91
|
+
# @yieldreturn [String, Symbol] the media category inferred from the media, when none is given
|
|
92
|
+
# @return [String] the media category in lowercase
|
|
93
|
+
# @raise [InvalidMedia] if the file does not exist
|
|
94
|
+
# @raise [InvalidMedia] if the media cannot be read, is empty, or is larger than the API takes of its category
|
|
95
|
+
# @raise [ArgumentError] if the media category is invalid, the alt text is empty or too long, the chunk size is
|
|
96
|
+
# not a positive Integer or is larger than a segment the API takes, the concurrency is not 1 to
|
|
97
|
+
# MAX_CONCURRENCY, the processing timeout is not a number of seconds, shared is neither true, false, nor nil,
|
|
98
|
+
# or additional_owners is neither nil nor an Array of at least one user identifier
|
|
99
|
+
# @example Validate the arguments of an upload
|
|
100
|
+
# Uploads::Validator.validate_upload!(source, :TWEET_IMAGE, alt_text: nil, chunk_size: nil, concurrency: 4,
|
|
101
|
+
# processing_timeout: 300) # => "tweet_image"
|
|
102
|
+
def validate_upload!(source, media_category, alt_text:, chunk_size:, concurrency:, processing_timeout:, shared: nil, additional_owners: nil)
|
|
103
|
+
validate_sharing!(shared, additional_owners)
|
|
104
|
+
validate_source!(source)
|
|
105
|
+
validate_alt_text!(alt_text)
|
|
106
|
+
validate_chunks!(chunk_size:, concurrency:)
|
|
107
|
+
validate_processing_timeout!(processing_timeout)
|
|
108
|
+
validate_media_category!(media_category || yield).tap { |category| validate_size!(source, category) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Validate that media is no larger than the API takes of its category
|
|
112
|
+
#
|
|
113
|
+
# An image, a GIF, or subtitles larger than the MAX_MEDIA_BYTES of its category would be refused once it had
|
|
114
|
+
# been uploaded, and billed, so it raises before a request. A video, whose size depends on the account, is left
|
|
115
|
+
# to the API, unless it is larger than the MAX_UPLOAD_BYTES the API takes of any upload, which it would refuse
|
|
116
|
+
# to initialize.
|
|
117
|
+
#
|
|
118
|
+
# @api private
|
|
119
|
+
# @param source [Source] the media to upload
|
|
120
|
+
# @param media_category [String] the media category, in lowercase
|
|
121
|
+
# @return [void]
|
|
122
|
+
# @raise [InvalidMedia] if the media is larger than the API takes of its category
|
|
123
|
+
# @example Validate the size of an image
|
|
124
|
+
# Uploads::Validator.validate_size!(source, "tweet_image")
|
|
125
|
+
def validate_size!(source, media_category)
|
|
126
|
+
limit = MAX_MEDIA_BYTES.fetch(media_category, MAX_UPLOAD_BYTES)
|
|
127
|
+
return if source.size <= limit
|
|
128
|
+
|
|
129
|
+
raise InvalidMedia, "#{source.description} is #{source.size} bytes, more than the #{limit} bytes the API takes of #{media_category} media"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Validate that the media exists, and that it holds something to upload
|
|
133
|
+
#
|
|
134
|
+
# Empty media would initialize an upload in chunks and finalize it without a chunk, or send a single request
|
|
135
|
+
# without media, for the API to refuse either.
|
|
136
|
+
#
|
|
137
|
+
# @api private
|
|
138
|
+
# @param source [Source] the media to validate
|
|
139
|
+
# @return [void]
|
|
140
|
+
# @raise [InvalidMedia] if the file does not exist
|
|
141
|
+
# @raise [InvalidMedia] if the media cannot be read, or is empty
|
|
142
|
+
# @example Validate the media of an upload
|
|
143
|
+
# Uploads::Validator.validate_source!(source)
|
|
144
|
+
def validate_source!(source)
|
|
145
|
+
raise InvalidMedia, "#{source.description} does not exist: there is no file to upload" unless source.exist?
|
|
146
|
+
raise InvalidMedia, "#{source.description} cannot be read: it is not a file, or not one open for reading" unless source.readable?
|
|
147
|
+
raise InvalidMedia, "#{source.description} is empty: there is nothing to upload" if source.size.zero?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Validate an image to upload as a profile image or banner
|
|
151
|
+
#
|
|
152
|
+
# The image must begin with the signature of a GIF, a JPEG, or a PNG, which are the types those take, whatever
|
|
153
|
+
# the name of its file, so that a PNG in a Tempfile is taken, and a video named .png is not read whole and sent.
|
|
154
|
+
# An image larger than the endpoint takes would be refused once it had been sent, so it raises before.
|
|
155
|
+
#
|
|
156
|
+
# @api private
|
|
157
|
+
# @param source [Source] the image to upload
|
|
158
|
+
# @param max_bytes [Integer] the greatest number of bytes the endpoint takes
|
|
159
|
+
# @param use [String] what the image is uploaded as, for the message of an error
|
|
160
|
+
# @return [void]
|
|
161
|
+
# @raise [InvalidMedia] if the file does not exist
|
|
162
|
+
# @raise [InvalidMedia] if the image cannot be read, is empty, or is larger than max_bytes
|
|
163
|
+
# @raise [InvalidMediaType] if the image does not begin with the signature of a GIF, a JPEG, or a PNG
|
|
164
|
+
# @example Validate a profile image
|
|
165
|
+
# Uploads::Validator.validate_profile_image!(source, 716_800, "a profile image")
|
|
166
|
+
def validate_profile_image!(source, max_bytes, use)
|
|
167
|
+
validate_source!(source)
|
|
168
|
+
raise InvalidMediaType, "#{source.description} is not a GIF, JPEG, or PNG image, which #{use} must be" unless PROFILE_IMAGE_TYPES.include?(Signature.media_type(source.sniff))
|
|
169
|
+
return if source.size <= max_bytes
|
|
170
|
+
|
|
171
|
+
raise InvalidMedia, "#{source.description} is #{source.size} bytes, more than the #{max_bytes} bytes the API takes of #{use}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Validate the region of a profile banner to crop it to
|
|
175
|
+
#
|
|
176
|
+
# The reference of the API v1.1 for update_profile_banner takes the width and height of the region of the image
|
|
177
|
+
# to use, and its offsets from the left and the top, each in pixels. A width or height is a positive Integer,
|
|
178
|
+
# and an offset an Integer of at least 0, or nil for none, so that anything else, such as a String read from a
|
|
179
|
+
# form, raises before any request, rather than be sent as whatever its to_s reads.
|
|
180
|
+
#
|
|
181
|
+
# @api private
|
|
182
|
+
# @param region [Hash{Symbol => Integer, nil}] the width and height of the region, and its offset_left and
|
|
183
|
+
# offset_top, in pixels
|
|
184
|
+
# @return [void]
|
|
185
|
+
# @raise [ArgumentError] if a width or height is not a positive Integer, or an offset not an Integer of at
|
|
186
|
+
# least 0, and is not nil
|
|
187
|
+
# @example Validate the region of a banner
|
|
188
|
+
# Uploads::Validator.validate_banner_region!(width: 1500, height: 500, offset_left: 0, offset_top: nil)
|
|
189
|
+
def validate_banner_region!(**region)
|
|
190
|
+
region.each do |name, pixels|
|
|
191
|
+
least = BANNER_REGION.fetch(name)
|
|
192
|
+
next if pixels.nil? || whole_number?(pixels, least)
|
|
193
|
+
|
|
194
|
+
raise ArgumentError, "#{name} must be an Integer of pixels of at least #{least}, or nil, not #{pixels.inspect}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Validate whether media is shared, and the users given access to it
|
|
199
|
+
#
|
|
200
|
+
# Media is shared, or not, with true or false, so that a value read from a String, such as "false", which is
|
|
201
|
+
# truthy, does not share it by accident. The users given access to it are named by their identifiers, each an
|
|
202
|
+
# Integer or a String of digits, as the API takes them, of at least one user, since a list of none names no one.
|
|
203
|
+
#
|
|
204
|
+
# @api private
|
|
205
|
+
# @param shared [Boolean, nil] whether the media is shared, or nil to leave it to the API
|
|
206
|
+
# @param additional_owners [Array<Integer, String>, nil] the identifiers of the users who may use the media, or
|
|
207
|
+
# nil for none
|
|
208
|
+
# @return [void]
|
|
209
|
+
# @raise [ArgumentError] if shared is neither true, false, nor nil, or additional_owners is neither nil nor an
|
|
210
|
+
# Array of at least one user identifier
|
|
211
|
+
# @example Validate media shared with another user
|
|
212
|
+
# Uploads::Validator.validate_sharing!(true, [7_505_382])
|
|
213
|
+
def validate_sharing!(shared, additional_owners)
|
|
214
|
+
raise ArgumentError, "shared must be true, false, or nil, not #{shared.inspect}" unless [true, false, nil].include?(shared)
|
|
215
|
+
return if additional_owners.nil?
|
|
216
|
+
return if additional_owners.is_a?(Array) && !additional_owners.empty? && additional_owners.all? { |owner| user_id?(owner) }
|
|
217
|
+
|
|
218
|
+
raise ArgumentError, "additional_owners must be an Array of the identifiers of users, or nil for none, not #{additional_owners.inspect}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Check whether a value is the identifier of a user, as the API takes it
|
|
222
|
+
#
|
|
223
|
+
# @api private
|
|
224
|
+
# @param value [Object] the value
|
|
225
|
+
# @return [Boolean] true for an Integer, or a String of digits, of 1 to 19 digits
|
|
226
|
+
def user_id?(value) = (value.instance_of?(Integer) || value.is_a?(String)) && value.to_s.match?(USER_ID)
|
|
227
|
+
|
|
228
|
+
# Validate the alt text of an upload, of up to MAX_ALT_TEXT_LENGTH characters
|
|
229
|
+
#
|
|
230
|
+
# The alt text is sent as JSON, which is UTF-8, so text of another encoding is sent as the UTF-8 it converts to,
|
|
231
|
+
# and text that converts to none, such as bytes that are not UTF-8, is refused here, before the media it
|
|
232
|
+
# describes is uploaded, rather than fail to be sent once it is.
|
|
233
|
+
#
|
|
234
|
+
# @api private
|
|
235
|
+
# @param alt_text [String, nil] the alt text to validate, or nil for media described with none
|
|
236
|
+
# @return [void]
|
|
237
|
+
# @raise [ArgumentError] if the alt text is not a String, does not convert to UTF-8, or is empty or longer than
|
|
238
|
+
# the API takes
|
|
239
|
+
# @example Validate alt text
|
|
240
|
+
# Uploads::Validator.validate_alt_text!("A cat asleep on a keyboard")
|
|
241
|
+
def validate_alt_text!(alt_text)
|
|
242
|
+
return if alt_text.nil?
|
|
243
|
+
raise ArgumentError, "alt_text must be a String, or nil for none, not #{alt_text.inspect}" unless alt_text.is_a?(String)
|
|
244
|
+
raise ArgumentError, "alt_text must be text that converts to UTF-8, not #{alt_text.inspect}" unless utf8?(alt_text)
|
|
245
|
+
return if (1..MAX_ALT_TEXT_LENGTH).cover?(alt_text.length)
|
|
246
|
+
|
|
247
|
+
raise ArgumentError, "alt_text must be 1 to #{MAX_ALT_TEXT_LENGTH} characters, not #{alt_text.length}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Check whether text converts to UTF-8, as the JSON it is sent in is written
|
|
251
|
+
# @api private
|
|
252
|
+
# @param text [String] the text
|
|
253
|
+
# @return [Boolean] true if the text converts to valid UTF-8
|
|
254
|
+
# @example Check text that holds a byte that is not UTF-8
|
|
255
|
+
# Uploads::Validator.utf8?("\xFF".b) # => false
|
|
256
|
+
def utf8?(text)
|
|
257
|
+
text.encode(Encoding::UTF_8).valid_encoding?
|
|
258
|
+
rescue EncodingError
|
|
259
|
+
false
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Validate the language code of subtitles, and upcase it, as the API takes it
|
|
263
|
+
#
|
|
264
|
+
# The code is two letters, in any case.
|
|
265
|
+
#
|
|
266
|
+
# @api private
|
|
267
|
+
# @param language_code [String] the language code, such as EN or en
|
|
268
|
+
# @return [String] the language code in upper case
|
|
269
|
+
# @raise [ArgumentError] if the language code is not two letters
|
|
270
|
+
# @example Validate a language code
|
|
271
|
+
# Uploads::Validator.validate_language_code!("en") # => "EN"
|
|
272
|
+
def validate_language_code!(language_code)
|
|
273
|
+
return language_code.upcase if language_code.is_a?(String) && language_code.match?(LANGUAGE_CODE)
|
|
274
|
+
|
|
275
|
+
raise ArgumentError, "language_code must be two letters, such as EN, not #{language_code.inspect}"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Validate the chunk size and concurrency of a chunked upload
|
|
279
|
+
#
|
|
280
|
+
# A chunk size is a whole number of bytes, so anything else, such as a Float or a String read from an
|
|
281
|
+
# environment variable, raises ArgumentError, rather than be rounded to one. A chunk size above the MAX_CHUNK
|
|
282
|
+
# bytes of a segment raises too, since the server would refuse the first segment, once the upload had been
|
|
283
|
+
# initialized. A concurrency above MAX_CONCURRENCY raises as well, rather than hold a chunk and a connection for
|
|
284
|
+
# each of as many threads as it names.
|
|
285
|
+
#
|
|
286
|
+
# @api private
|
|
287
|
+
# @param chunk_size [Integer, nil] the size of each chunk in bytes, which must be positive and at most
|
|
288
|
+
# MAX_CHUNK, or nil for a chunk size derived from the file
|
|
289
|
+
# @param concurrency [Integer] the number of chunks uploaded at once, of 1 to MAX_CONCURRENCY
|
|
290
|
+
# @return [void]
|
|
291
|
+
# @raise [ArgumentError] if the chunk size is not a positive Integer, or is larger than a segment the API takes,
|
|
292
|
+
# or the concurrency is not an Integer of 1 to MAX_CONCURRENCY
|
|
293
|
+
# @example Validate the options of a chunked upload
|
|
294
|
+
# Uploads::Validator.validate_chunks!(chunk_size: 4_194_304, concurrency: 2)
|
|
295
|
+
def validate_chunks!(chunk_size:, concurrency:)
|
|
296
|
+
raise ArgumentError, "chunk_size must be a positive Integer of bytes, not #{chunk_size.inspect}" unless chunk_size.nil? || whole_number?(chunk_size, 1)
|
|
297
|
+
raise ArgumentError, "chunk_size must be at most #{MAX_CHUNK}, the bytes of a segment the API takes, not #{chunk_size}" if chunk_size && chunk_size > MAX_CHUNK
|
|
298
|
+
raise ArgumentError, "concurrency must be an Integer of 1 to #{MAX_CONCURRENCY}, not #{concurrency.inspect}" unless concurrency.instance_of?(Integer) && (1..MAX_CONCURRENCY).cover?(concurrency)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Check whether a value is a whole number of at least the least given
|
|
302
|
+
#
|
|
303
|
+
# A number of bytes or pixels is one. A Float is not, even a whole one, nor is a Rational, so that no size is
|
|
304
|
+
# rounded to a byte or a pixel.
|
|
305
|
+
#
|
|
306
|
+
# @api private
|
|
307
|
+
# @param value [Object] the value
|
|
308
|
+
# @param least [Integer] the least the number may be
|
|
309
|
+
# @return [Boolean] true if the value is an Integer of at least the least given
|
|
310
|
+
def whole_number?(value, least) = value.instance_of?(Integer) && value >= least
|
|
311
|
+
|
|
312
|
+
# Validate the seconds to wait for media to process
|
|
313
|
+
#
|
|
314
|
+
# A processing timeout is a finite number of seconds of at least 0, or nil for an upload that waits for as long
|
|
315
|
+
# as processing takes, as a timeout of a client is.
|
|
316
|
+
#
|
|
317
|
+
# @api private
|
|
318
|
+
# @param processing_timeout [Integer, Float, nil] the seconds to wait, or nil for no timeout
|
|
319
|
+
# @return [void]
|
|
320
|
+
# @raise [ArgumentError] if the processing timeout is neither a finite number of seconds of at least 0 nor nil
|
|
321
|
+
# @example Validate a processing timeout
|
|
322
|
+
# Uploads::Validator.validate_processing_timeout!(1800)
|
|
323
|
+
def validate_processing_timeout!(processing_timeout)
|
|
324
|
+
return if processing_timeout.nil? || finite_seconds?(processing_timeout)
|
|
325
|
+
|
|
326
|
+
raise ArgumentError, "processing_timeout must be a finite number of seconds of at least 0, or nil for no " \
|
|
327
|
+
"timeout, not #{processing_timeout.inspect}"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Check whether a value is a finite real number of at least 0
|
|
331
|
+
#
|
|
332
|
+
# @api private
|
|
333
|
+
# @param value [Object] the value
|
|
334
|
+
# @return [Boolean] true if the value is a finite real number of at least 0
|
|
335
|
+
def finite_seconds?(value) = value.is_a?(Numeric) && value.real? && !value.negative? && value.finite?
|
|
336
|
+
|
|
337
|
+
# The size in bytes of the chunks a file uploads in
|
|
338
|
+
#
|
|
339
|
+
# The API numbers no more than MAX_SEGMENTS segments, of no more than MAX_CHUNK bytes each, which an upload in
|
|
340
|
+
# more chunks, or in larger ones, would fail partway of.
|
|
341
|
+
#
|
|
342
|
+
# A chunk size of nil is derived from the size of the media: DEFAULT_CHUNK bytes, or the size that uploads the
|
|
343
|
+
# media in MAX_SEGMENTS chunks, whichever is larger, which media no larger than the MAX_UPLOAD_BYTES
|
|
344
|
+
# validate_size! takes uploads in chunks of DEFAULT_CHUNK bytes.
|
|
345
|
+
#
|
|
346
|
+
# @api private
|
|
347
|
+
# @param source [Source] the media to upload
|
|
348
|
+
# @param chunk_size [Integer, nil] the size of each chunk in bytes, or nil to derive one
|
|
349
|
+
# @return [Integer] the size of each chunk in bytes
|
|
350
|
+
# @raise [InvalidMedia] if the system refuses to read the size of the media, as it does that of a file that
|
|
351
|
+
# does not exist
|
|
352
|
+
# @raise [ArgumentError] if chunks of the size given would be more than the API numbers
|
|
353
|
+
# @example Derive the chunk size of a video
|
|
354
|
+
# Uploads::Validator.validate_segments!(source, nil) # => 4194304
|
|
355
|
+
def validate_segments!(source, chunk_size)
|
|
356
|
+
file_size = source.size
|
|
357
|
+
size = chunk_size || derived_chunk_size(file_size)
|
|
358
|
+
return size if file_size <= size * MAX_SEGMENTS
|
|
359
|
+
|
|
360
|
+
raise ArgumentError, "chunk_size of #{chunk_size} bytes uploads #{file_size} bytes in more than the #{MAX_SEGMENTS} segments the API numbers"
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# The size in bytes of the chunks media is uploaded in when it is given none
|
|
364
|
+
#
|
|
365
|
+
# It is DEFAULT_CHUNK bytes, or the size that uploads the media in MAX_SEGMENTS chunks, whichever is larger.
|
|
366
|
+
#
|
|
367
|
+
# @api private
|
|
368
|
+
# @param file_size [Integer] the size of the media in bytes
|
|
369
|
+
# @return [Integer] the size of each chunk in bytes
|
|
370
|
+
# @example The chunk size of a video of sixteen gigabytes
|
|
371
|
+
# Uploads::Validator.derived_chunk_size(16 * 1024**3) # => 4194304
|
|
372
|
+
def derived_chunk_size(file_size) = [(file_size.to_f / MAX_SEGMENTS).ceil, DEFAULT_CHUNK].max
|
|
373
|
+
|
|
374
|
+
# Validate a media category, and give it in the lowercase the API takes
|
|
375
|
+
#
|
|
376
|
+
# @api private
|
|
377
|
+
# @param media_category [String, Symbol] the media category to validate, in any case
|
|
378
|
+
# @return [String] the media category in lowercase
|
|
379
|
+
# @raise [ArgumentError] if the media category is invalid
|
|
380
|
+
# @example Validate a media category
|
|
381
|
+
# Uploads::Validator.validate_media_category!(:TWEET_IMAGE) # => "tweet_image"
|
|
382
|
+
def validate_media_category!(media_category)
|
|
383
|
+
category = media_category.to_s.downcase
|
|
384
|
+
return category if MEDIA_CATEGORIES.include?(category)
|
|
385
|
+
|
|
386
|
+
raise ArgumentError, "Invalid media_category: #{media_category}. Valid values: #{MEDIA_CATEGORIES.join(", ")}"
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
private_constant :Validator
|
|
390
|
+
end
|
|
391
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems/version"
|
|
4
|
+
|
|
5
|
+
module X
|
|
6
|
+
# Uploads media, profile images, and banners to the X API
|
|
7
|
+
# @api public
|
|
8
|
+
module Uploads
|
|
9
|
+
# The current version of the x-uploads gem
|
|
10
|
+
VERSION = "1.0.0"
|
|
11
|
+
|
|
12
|
+
# The version as a Gem::Version, which compares one release with another
|
|
13
|
+
#
|
|
14
|
+
# VERSION is a String, as a version constant is throughout Ruby, so that what reads it can split it, match it,
|
|
15
|
+
# or send it wherever a String belongs. This builds the Gem::Version that compares it with another version,
|
|
16
|
+
# which a String compares by character rather than by segment.
|
|
17
|
+
#
|
|
18
|
+
# @api public
|
|
19
|
+
# @return [Gem::Version] the version
|
|
20
|
+
# @example Take a path that a later release opened
|
|
21
|
+
# X::Uploads.gem_version >= Gem::Version.new("1.1")
|
|
22
|
+
def self.gem_version = Gem::Version.new(VERSION)
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/x/uploads.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "uploads/version"
|
|
4
|
+
require_relative "uploads/account"
|
|
5
|
+
require_relative "uploads/alt_text_failed"
|
|
6
|
+
require_relative "uploads/api"
|
|
7
|
+
require_relative "uploads/chunked_upload_failed"
|
|
8
|
+
require_relative "uploads/error"
|
|
9
|
+
require_relative "uploads/gif"
|
|
10
|
+
require_relative "uploads/invalid_media"
|
|
11
|
+
require_relative "uploads/invalid_media_type"
|
|
12
|
+
require_relative "uploads/media_upload"
|
|
13
|
+
require_relative "uploads/media_processing_check_failed"
|
|
14
|
+
require_relative "uploads/media_processing_failed"
|
|
15
|
+
require_relative "uploads/media_processing_timeout"
|
|
16
|
+
require_relative "uploads/metadata"
|
|
17
|
+
require_relative "uploads/missing_media_data"
|
|
18
|
+
require_relative "uploads/uploaded_media"
|
data/sig/manifest.yaml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# The standard libraries the signatures of x-uploads refer to, which rbs collection loads for code that depends on it
|
|
2
|
+
#
|
|
3
|
+
# rbs collection reads every library named here as a standard library, so the gems x-uploads depends on are left to its
|
|
4
|
+
# gemspec, from which it installs their signatures.
|
|
5
|
+
dependencies:
|
|
6
|
+
- name: json
|
data/sig/x-uploads.rbs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
module X
|
|
2
|
+
module Uploads
|
|
3
|
+
VERSION: String
|
|
4
|
+
|
|
5
|
+
def self.gem_version: () -> Gem::Version
|
|
6
|
+
|
|
7
|
+
# Media that has a media key, which names its media identifier, such as the X::Media of x-resources
|
|
8
|
+
interface _MediaKeyed
|
|
9
|
+
def media_key: () -> String?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Media to act on: what an upload returned, media that has a media key, or the identifier of media
|
|
13
|
+
type media = UploadedMedia | Hash[String, untyped] | _MediaKeyed | String | Integer
|
|
14
|
+
|
|
15
|
+
# Media to upload: a path to it, or an IO open on it
|
|
16
|
+
type uploadable = String | _ToPath | _Reader
|
|
17
|
+
|
|
18
|
+
module API
|
|
19
|
+
def upload_media: (uploadable media, ?media_category: (String | Symbol)?, ?alt_text: String?, ?processing_timeout: Numeric?, ?media_type: String?, ?chunk_size: Integer?, ?concurrency: Integer, ?shared: bool?, ?additional_owners: Array[Integer | String]?) -> UploadedMedia
|
|
20
|
+
def chunked_upload_media: (uploadable media, ?media_category: (String | Symbol)?, ?media_type: String?, ?chunk_size: Integer?, ?concurrency: Integer, ?shared: bool?, ?additional_owners: Array[Integer | String]?) -> UploadedMedia
|
|
21
|
+
def await_media_processing: (media media, ?processing_timeout: Numeric?) -> UploadedMedia
|
|
22
|
+
def await_media_processing!: (media media, ?processing_timeout: Numeric?) -> UploadedMedia
|
|
23
|
+
def add_alt_text: (media media, String text) -> UploadedMedia
|
|
24
|
+
def add_subtitles: (media video, media subtitles, String language_code, ?display_name: String?, ?media_category: String | Symbol) -> UploadedMedia
|
|
25
|
+
def update_profile_image: (uploadable media) -> void
|
|
26
|
+
def update_profile_banner: (uploadable media, ?width: Integer?, ?height: Integer?, ?offset_left: Integer?, ?offset_top: Integer?) -> void
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class Error < ::X::Error
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
module MediaUpload
|
|
33
|
+
AMPLIFY_VIDEO: String
|
|
34
|
+
DM_GIF: String
|
|
35
|
+
DM_IMAGE: String
|
|
36
|
+
DM_VIDEO: String
|
|
37
|
+
SUBTITLES: String
|
|
38
|
+
TWEET_GIF: String
|
|
39
|
+
TWEET_IMAGE: String
|
|
40
|
+
TWEET_VIDEO: String
|
|
41
|
+
DEFAULT_PROCESSING_TIMEOUT: Integer
|
|
42
|
+
DEFAULT_CONCURRENCY: Integer
|
|
43
|
+
DEFAULT_CHUNK_SIZE: Integer
|
|
44
|
+
MAX_CONCURRENCY: Integer
|
|
45
|
+
extend MediaUpload
|
|
46
|
+
|
|
47
|
+
def upload: (uploadable media, client: ::X::Client, ?media_category: (String | Symbol)?, ?alt_text: String?, ?processing_timeout: Numeric?, ?media_type: String?, ?chunk_size: Integer?, ?concurrency: Integer, ?shared: bool?, ?additional_owners: Array[Integer | String]?) -> UploadedMedia
|
|
48
|
+
def chunked_upload: (uploadable media, client: ::X::Client, ?media_category: (String | Symbol)?, ?media_type: String?, ?chunk_size: Integer?, ?concurrency: Integer, ?shared: bool?, ?additional_owners: Array[Integer | String]?) -> UploadedMedia
|
|
49
|
+
def await_processing: (media media, client: ::X::Client, ?processing_timeout: Numeric?) -> UploadedMedia
|
|
50
|
+
def await_processing!: (media media, client: ::X::Client, ?processing_timeout: Numeric?) -> UploadedMedia
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
module Metadata
|
|
54
|
+
extend Metadata
|
|
55
|
+
|
|
56
|
+
def add_alt_text: (media media, String text, client: ::X::Client) -> UploadedMedia
|
|
57
|
+
def add_subtitles: (media video, media subtitles, String language_code, client: ::X::Client, ?display_name: String?, ?media_category: String | Symbol) -> UploadedMedia
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
module Account
|
|
61
|
+
extend Account
|
|
62
|
+
|
|
63
|
+
def update_profile_image: (uploadable media, client: ::X::Client) -> void
|
|
64
|
+
def update_profile_banner: (uploadable media, client: ::X::Client, ?width: Integer?, ?height: Integer?, ?offset_left: Integer?, ?offset_top: Integer?) -> void
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class UploadedMedia
|
|
69
|
+
attr_reader attrs: Hash[String, untyped]
|
|
70
|
+
alias to_h attrs
|
|
71
|
+
|
|
72
|
+
def initialize: (Hash[String, untyped] attrs) -> void
|
|
73
|
+
def id: () -> Integer
|
|
74
|
+
def media_id: () -> Integer
|
|
75
|
+
def media_key: () -> String?
|
|
76
|
+
def bytesize: () -> Integer?
|
|
77
|
+
def expires_after_secs: () -> Integer?
|
|
78
|
+
def processing_info: () -> Hash[String, untyped]?
|
|
79
|
+
def state: () -> String?
|
|
80
|
+
def check_after_secs: () -> Integer?
|
|
81
|
+
def processing?: () -> bool
|
|
82
|
+
def failed?: () -> bool
|
|
83
|
+
def ready?: () -> bool
|
|
84
|
+
def []: (String key) -> untyped
|
|
85
|
+
def fetch: (String key, *untyped default) ?{ (String key) -> untyped } -> untyped
|
|
86
|
+
def dig: (*String | Integer keys) -> untyped
|
|
87
|
+
def key?: (String key) -> bool
|
|
88
|
+
def as_json: (*untyped) -> Hash[String, untyped]
|
|
89
|
+
def to_json: (?::JSON::State? state) -> String
|
|
90
|
+
def ==: (untyped other) -> bool
|
|
91
|
+
alias eql? ==
|
|
92
|
+
def hash: () -> Integer
|
|
93
|
+
def inspect: () -> String
|
|
94
|
+
def marshal_dump: () -> untyped
|
|
95
|
+
def marshal_load: (untyped state) -> void
|
|
96
|
+
def encode_with: (untyped coder) -> void
|
|
97
|
+
def init_with: (untyped coder) -> void
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
class InvalidMedia < Uploads::Error
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
class InvalidMediaType < InvalidMedia
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
class MissingMediaData < Uploads::Error
|
|
107
|
+
attr_reader problems: Array[Problem]
|
|
108
|
+
def initialize: (?String? message, ?problems: Array[Problem]) -> void
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
class MediaProcessingTimeout < Uploads::Error
|
|
112
|
+
attr_reader media: UploadedMedia?
|
|
113
|
+
attr_reader timeout: Numeric?
|
|
114
|
+
def initialize: (?String? message, ?media: UploadedMedia | Hash[String, untyped] | nil, ?timeout: Numeric?) -> void
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
class MediaProcessingCheckFailed < Uploads::Error
|
|
118
|
+
attr_reader media: UploadedMedia?
|
|
119
|
+
def initialize: (?String? message, ?media: UploadedMedia | Hash[String, untyped] | nil) -> void
|
|
120
|
+
def to_s: () -> String
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
class ChunkedUploadFailed < Uploads::Error
|
|
124
|
+
attr_reader media: UploadedMedia?
|
|
125
|
+
def initialize: (?String? message, ?media: UploadedMedia | Hash[String, untyped] | nil) -> void
|
|
126
|
+
def to_s: () -> String
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class AltTextFailed < Uploads::Error
|
|
130
|
+
attr_reader media: UploadedMedia?
|
|
131
|
+
def initialize: (?String? message, ?media: UploadedMedia | Hash[String, untyped] | nil) -> void
|
|
132
|
+
def to_s: () -> String
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
class MediaProcessingFailed < Uploads::Error
|
|
136
|
+
attr_reader media: UploadedMedia?
|
|
137
|
+
def initialize: (?String? message, ?media: UploadedMedia | Hash[String, untyped] | nil) -> void
|
|
138
|
+
end
|
|
139
|
+
end
|