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,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "x/core"
|
|
4
|
+
require_relative "media_processing_failed"
|
|
5
|
+
require_relative "media_processing_timeout"
|
|
6
|
+
require_relative "missing_media_data"
|
|
7
|
+
require_relative "multipart"
|
|
8
|
+
require_relative "uploaded_media"
|
|
9
|
+
|
|
10
|
+
module X
|
|
11
|
+
module Uploads
|
|
12
|
+
# Helpers shared across the uploaders
|
|
13
|
+
#
|
|
14
|
+
# Internal to x-uploads: the uploaders call it rather than mix its methods into themselves, so a class that
|
|
15
|
+
# includes an uploader gains none of them.
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
module Utils
|
|
19
|
+
extend self
|
|
20
|
+
|
|
21
|
+
# The media categories the subtitles endpoint takes, by the category the video was uploaded as
|
|
22
|
+
SUBTITLED_MEDIA_CATEGORIES = {"tweet_video" => "TweetVideo", "amplify_video" => "AmplifyVideo"}.freeze
|
|
23
|
+
# The message of the error raised for media that holds no identifier
|
|
24
|
+
NO_MEDIA_ID = "The media given holds no identifier"
|
|
25
|
+
# The message of the error raised for a response of an upload that holds no media
|
|
26
|
+
NO_MEDIA = "The response %s holds no media"
|
|
27
|
+
# The message of the error raised for a metadata response that holds no metadata
|
|
28
|
+
NO_METADATA = "The response that adds the metadata holds none"
|
|
29
|
+
# The message of the error raised for something that is neither media nor the identifier of media
|
|
30
|
+
NOT_MEDIA = "%s is not media: pass uploaded media, the Hash of an upload response, media that has a media key, " \
|
|
31
|
+
"such as X::Media, a media key, or a media identifier"
|
|
32
|
+
# The message of the error raised for a media key that names no media identifier
|
|
33
|
+
NOT_MEDIA_KEY = "The media key %s names no media identifier"
|
|
34
|
+
# The pattern of a media key, which names the media identifier after the number of its type and an underscore
|
|
35
|
+
MEDIA_KEY = /\A\d+_(\d+)\z/
|
|
36
|
+
# The pattern of a media identifier the API takes: one to nineteen digits
|
|
37
|
+
MEDIA_ID = /\A\d{1,19}\z/
|
|
38
|
+
# The message of the error raised for a media identifier the API would refuse
|
|
39
|
+
NOT_MEDIA_ID = "The media identifier %s is none the API takes, which is 1 to 19 digits"
|
|
40
|
+
# Fewest seconds to wait before a check of processing, for a status that asks for no wait, as one in a state X
|
|
41
|
+
# does not document may
|
|
42
|
+
MIN_CHECK_AFTER_SECS = 1
|
|
43
|
+
private_constant :NO_MEDIA_ID, :NO_MEDIA, :NO_METADATA, :NOT_MEDIA, :NOT_MEDIA_KEY, :MEDIA_KEY, :MEDIA_ID, :NOT_MEDIA_ID,
|
|
44
|
+
:MIN_CHECK_AFTER_SECS
|
|
45
|
+
|
|
46
|
+
# The lowercase extension of a file, without its dot
|
|
47
|
+
#
|
|
48
|
+
# @api private
|
|
49
|
+
# @param file_path [String, Pathname] the path to the file
|
|
50
|
+
# @return [String] the extension
|
|
51
|
+
# @example The extension of a file
|
|
52
|
+
# Uploads::Utils.extension("cat.JPG") # => "jpg"
|
|
53
|
+
def extension(file_path) = File.extname(file_path).delete(".").downcase
|
|
54
|
+
|
|
55
|
+
# The options of an uploader a method of a client was given, which name no client
|
|
56
|
+
#
|
|
57
|
+
# A method of a client uploads with that client, so a client among the options, which would upload with the
|
|
58
|
+
# credentials of another, raises as a keyword the method does not take raises, rather than take its place.
|
|
59
|
+
#
|
|
60
|
+
# @api private
|
|
61
|
+
# @param options [Hash{Symbol => Object}] the options the method was given
|
|
62
|
+
# @return [Hash{Symbol => Object}] the options
|
|
63
|
+
# @raise [ArgumentError] if the options name a client
|
|
64
|
+
# @example The options of an upload of a client
|
|
65
|
+
# Uploads::Utils.without_client(media_category: "tweet_image") # => {media_category: "tweet_image"}
|
|
66
|
+
def without_client(options)
|
|
67
|
+
raise ArgumentError, "unknown keyword: :client" if options.key?(:client)
|
|
68
|
+
|
|
69
|
+
options
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The media identifier of an upload response, of media, or of an identifier
|
|
73
|
+
#
|
|
74
|
+
# Media that is not what an upload returned, such as the X::Media of x-resources, which this gem does not depend
|
|
75
|
+
# on, is read from its media key, which names the identifier after the number of its type, as 3_7 names 7, and
|
|
76
|
+
# a String that is a media key is read the same way, as the media_ids of a new post read one.
|
|
77
|
+
#
|
|
78
|
+
# Nil, or an empty identifier, names no media, and would reach the API as an identifier that is not there, so it
|
|
79
|
+
# raises ArgumentError, as a mistake of the caller: every response an upload builds media from holds an
|
|
80
|
+
# identifier, or raises MissingMediaData where it is read. Anything else raises rather than reach the API as
|
|
81
|
+
# whatever its to_s reads, such as the inspection of an object, and an identifier is read as strictly as
|
|
82
|
+
# UploadedMedia reads one: an Integer, or a String of digits alone, with no sign, underscore, or whitespace, so
|
|
83
|
+
# that the identifier of a Hash that is a Symbol or a Float raises too.
|
|
84
|
+
#
|
|
85
|
+
# @api private
|
|
86
|
+
# @param media [UploadedMedia, Hash, #media_key, String, Integer] the uploaded media, the upload response, media
|
|
87
|
+
# that has a media key, the media key, or the media identifier
|
|
88
|
+
# @return [String] the media identifier
|
|
89
|
+
# @raise [ArgumentError] if the media is nil or empty, neither media, a media key, nor a media identifier, holds
|
|
90
|
+
# no identifier, has no media key or one that names no identifier, or its identifier is none the API takes,
|
|
91
|
+
# which is 1 to 19 digits
|
|
92
|
+
# @example The identifier of uploaded media
|
|
93
|
+
# Uploads::Utils.media_id({"id" => "1880028106020515840"}) # => "1880028106020515840"
|
|
94
|
+
# @example The identifier of media of a post
|
|
95
|
+
# Uploads::Utils.media_id(X::Media.new(media_key: "3_1880028106020515840")) # => "1880028106020515840"
|
|
96
|
+
# @example The identifier a media key names
|
|
97
|
+
# Uploads::Utils.media_id("3_1880028106020515840") # => "1880028106020515840"
|
|
98
|
+
def media_id(media)
|
|
99
|
+
id = case media
|
|
100
|
+
when Hash, UploadedMedia then media.fetch("id", nil)
|
|
101
|
+
when String then media[MEDIA_KEY, 1] || media
|
|
102
|
+
when Integer, nil then media
|
|
103
|
+
else media_key_id(media)
|
|
104
|
+
end
|
|
105
|
+
checked_media_id(id)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A media identifier, as the String the API takes, once it is checked
|
|
109
|
+
#
|
|
110
|
+
# @api private
|
|
111
|
+
# @param id [Object] the identifier media holds, or its media key names
|
|
112
|
+
# @return [String] the media identifier
|
|
113
|
+
# @raise [ArgumentError] if the identifier is nil or empty, or is neither an Integer nor a String of 1 to 19
|
|
114
|
+
# digits alone
|
|
115
|
+
# @example Check the identifier of uploaded media
|
|
116
|
+
# Uploads::Utils.checked_media_id(7) # => "7"
|
|
117
|
+
def checked_media_id(id)
|
|
118
|
+
text = id.to_s
|
|
119
|
+
raise ArgumentError, NO_MEDIA_ID if text.empty?
|
|
120
|
+
return text if (Integer === id || String === id) && text.match?(MEDIA_ID)
|
|
121
|
+
|
|
122
|
+
raise ArgumentError, format(NOT_MEDIA_ID, id.inspect)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The media identifier the media key of media names
|
|
126
|
+
#
|
|
127
|
+
# @api private
|
|
128
|
+
# @param media [#media_key, Object] the media
|
|
129
|
+
# @return [String, nil] the media identifier, or nil for media that has no media key
|
|
130
|
+
# @raise [ArgumentError] if the media has no media_key, or its media key names no identifier
|
|
131
|
+
# @example The identifier of media of a post
|
|
132
|
+
# Uploads::Utils.media_key_id(X::Media.new(media_key: "3_7")) # => "7"
|
|
133
|
+
def media_key_id(media)
|
|
134
|
+
raise ArgumentError, format(NOT_MEDIA, media.inspect) unless media.respond_to?(:media_key)
|
|
135
|
+
|
|
136
|
+
key = media.media_key
|
|
137
|
+
key && (key.to_s[MEDIA_KEY, 1] || raise(ArgumentError, format(NOT_MEDIA_KEY, key.inspect)))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Media as uploaded media, which what an upload returned already is
|
|
141
|
+
#
|
|
142
|
+
# The Hash of an upload response is built into the uploaded media it describes, once its identifier is checked as
|
|
143
|
+
# the identifier of any media given is, and media known by a media key or by a media identifier into uploaded
|
|
144
|
+
# media that holds its identifier, and its media key if it has one.
|
|
145
|
+
#
|
|
146
|
+
# @api private
|
|
147
|
+
# @param media [UploadedMedia, Hash, #media_key, String, Integer] the uploaded media, the upload response, media
|
|
148
|
+
# that has a media key, the media key, or the media identifier
|
|
149
|
+
# @return [UploadedMedia] the uploaded media
|
|
150
|
+
# @raise [ArgumentError] if the media is nil or empty, neither media, a media key, nor a media identifier, holds
|
|
151
|
+
# no identifier the API takes, or its media key names none
|
|
152
|
+
# @example Uploaded media known by its identifier
|
|
153
|
+
# Uploads::Utils.uploaded_media(7) # => #<X::UploadedMedia id=7 media_key=nil state=nil>
|
|
154
|
+
def uploaded_media(media)
|
|
155
|
+
case media
|
|
156
|
+
when UploadedMedia then media
|
|
157
|
+
when Hash
|
|
158
|
+
media_id(media)
|
|
159
|
+
UploadedMedia.new(media)
|
|
160
|
+
else
|
|
161
|
+
keyed = media #: untyped
|
|
162
|
+
UploadedMedia.new({"id" => media_id(media), "media_key" => (keyed.media_key if keyed.respond_to?(:media_key))}.compact)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The media metadata was added to, once the response says it was added
|
|
167
|
+
#
|
|
168
|
+
# The API answers a change of metadata with the media identifier and the metadata it now holds, under the data
|
|
169
|
+
# of the response, which a response that succeeded without it does not say were added.
|
|
170
|
+
#
|
|
171
|
+
# @api private
|
|
172
|
+
# @param response [Hash, nil] the parsed response body, or nil for a response without one
|
|
173
|
+
# @param media [UploadedMedia, Hash, #media_key, String, Integer] the media the metadata was added to
|
|
174
|
+
# @return [UploadedMedia] the media, as uploaded media
|
|
175
|
+
# @raise [MissingMediaData] if the response holds no metadata
|
|
176
|
+
# @example The media alt text was added to
|
|
177
|
+
# Uploads::Utils.described({"data" => {"id" => "7"}}, media) # => media
|
|
178
|
+
def described(response, media)
|
|
179
|
+
body = body_of(response)
|
|
180
|
+
raise MissingMediaData.new(NO_METADATA, problems: Problem.all_from(body)) unless Hash.try_convert(body["data"])
|
|
181
|
+
|
|
182
|
+
uploaded_media(media)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The media a response of an upload describes
|
|
186
|
+
#
|
|
187
|
+
# The API answers an upload with what it acted on, under the data of the response, which always holds the
|
|
188
|
+
# identifier of the media. A response that succeeded without it describes no media, whether it carries no body
|
|
189
|
+
# at all, a body without data, or data whose identifier is missing, nil, or empty, so it raises rather than
|
|
190
|
+
# leave the upload to fail later on what is missing, or return media that cannot be attached for media the API
|
|
191
|
+
# may have billed. What it returns holds an identifier, so media that holds none was given by the caller.
|
|
192
|
+
#
|
|
193
|
+
# @api private
|
|
194
|
+
# @param response [Hash, nil] the parsed response body, or nil for a response without one
|
|
195
|
+
# @param description [String] how the error names the response, for its message
|
|
196
|
+
# @return [Hash] the media
|
|
197
|
+
# @raise [MissingMediaData] if the response holds no media, or media without an identifier
|
|
198
|
+
# @example The media an upload returned
|
|
199
|
+
# Uploads::Utils.media_data({"data" => {"id" => 7}}, "of the upload") # => {"id" => 7}
|
|
200
|
+
def media_data(response, description)
|
|
201
|
+
body = body_of(response)
|
|
202
|
+
media = body["data"]
|
|
203
|
+
return media if UploadedMedia.__send__(:documented?, media)
|
|
204
|
+
|
|
205
|
+
raise MissingMediaData.new(format(NO_MEDIA, description), problems: Problem.all_from(body))
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Upload media in a single request
|
|
209
|
+
#
|
|
210
|
+
# An image is uploaded so, as is a GIF a single request takes.
|
|
211
|
+
#
|
|
212
|
+
# @api private
|
|
213
|
+
# @param client [Client] the X API client
|
|
214
|
+
# @param content [String] the whole of the media, once its category is known to take it
|
|
215
|
+
# @param media_category [String] the media category, in lowercase
|
|
216
|
+
# @param additional_owners [Array<Integer, String>, nil] the identifiers of the users who may use the media, or
|
|
217
|
+
# nil for none, which the request sends as a list separated by commas
|
|
218
|
+
# @return [UploadedMedia] the uploaded media, which holds the upload response
|
|
219
|
+
# @raise [MissingMediaData] if the response holds no media, or carries no body at all
|
|
220
|
+
# @example Upload an image
|
|
221
|
+
# Uploads::Utils.single_request(client, png, "tweet_image", additional_owners: nil)
|
|
222
|
+
def single_request(client, content, media_category, additional_owners:)
|
|
223
|
+
response = Multipart.post(client, "media/upload", "media", content, media_category:, additional_owners: additional_owners&.join(","))
|
|
224
|
+
UploadedMedia.new(media_data(response, "of the upload"))
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The body of a response as an object, empty for one that holds none
|
|
228
|
+
#
|
|
229
|
+
# It is empty for a response that holds something other than an object, as a gateway may answer with.
|
|
230
|
+
#
|
|
231
|
+
# @api private
|
|
232
|
+
# @param response [Hash, Object, nil] the parsed body of the response
|
|
233
|
+
# @return [Hash] the body, or an empty Hash
|
|
234
|
+
# @example Read the body of a response
|
|
235
|
+
# X::Uploads::Utils.body_of(response)["data"]
|
|
236
|
+
def body_of(response) = Hash.try_convert(response).to_h
|
|
237
|
+
|
|
238
|
+
# Send a request again after a server or network error, as an idempotent one is
|
|
239
|
+
#
|
|
240
|
+
# A client sends no POST again, since the API may have acted on one whose answer never arrived, but some of
|
|
241
|
+
# the POSTs of an upload have the same effect sent twice as sent once, such as a chunk, which names the segment
|
|
242
|
+
# it is appended at. Those are sent again with the with_retries of the client, up to its max_retries, after the
|
|
243
|
+
# wait a failed response asks for, or a backoff that grows with each retry and is cut short at random, so that
|
|
244
|
+
# the requests one failure ended are not sent again together. They are sent again after a timeout too, which a
|
|
245
|
+
# client sends no read again after, since a lost answer would otherwise fail the upload; X bills nothing for a
|
|
246
|
+
# chunk, but bills each metadata request, so alt text or subtitles whose answer was lost may be billed twice. A
|
|
247
|
+
# client that has no with_retries, which X::Client has, sends each request as it sends any other.
|
|
248
|
+
#
|
|
249
|
+
# @api private
|
|
250
|
+
# @param client [Client] the X API client
|
|
251
|
+
# @yield sends the request
|
|
252
|
+
# @return [Object] what the block returns
|
|
253
|
+
# @example Append a chunk, again after a failure
|
|
254
|
+
# Uploads::Utils.sending_again(client) { client.post("media/upload/1/append", body, headers:) }
|
|
255
|
+
def sending_again(client, &)
|
|
256
|
+
retrying = client #: untyped
|
|
257
|
+
retrying.respond_to?(:with_retries) ? retrying.with_retries(&) : yield
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The processing status of media, unless the media failed to process
|
|
261
|
+
#
|
|
262
|
+
# The failed state alone is a failure: media whose processing names no state, or a state X does not document, is
|
|
263
|
+
# still processing, as a state X adds may be one it passes through, so it is waited for, not raised for. A
|
|
264
|
+
# response that holds no processing is of media X does not process, whatever else it holds.
|
|
265
|
+
#
|
|
266
|
+
# @api private
|
|
267
|
+
# @param status [UploadedMedia] the processing status X reported, or the response of an upload
|
|
268
|
+
# @return [UploadedMedia] the status, of media that has processed, is still processing, or needs no processing
|
|
269
|
+
# @raise [MediaProcessingFailed] if the media failed to process, with the status
|
|
270
|
+
# @example The status of media that has processed
|
|
271
|
+
# Uploads::Utils.processed!(status) # => status
|
|
272
|
+
def processed!(status)
|
|
273
|
+
raise MediaProcessingFailed.new(media: status) if status.failed?
|
|
274
|
+
|
|
275
|
+
status
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Wait as long as the status of media still processing asks
|
|
279
|
+
#
|
|
280
|
+
# It waits at least a second, which is how long it waits for a status that asks for no wait, as one in a state X
|
|
281
|
+
# does not document may, and gives up, rather than sleep, when the check would come after the deadline, which a
|
|
282
|
+
# wait with no deadline never does.
|
|
283
|
+
#
|
|
284
|
+
# @api private
|
|
285
|
+
# @param status [UploadedMedia] the status of the media, which is still processing
|
|
286
|
+
# @param deadline [Float, nil] the time on the monotonic clock to give up at, or nil to wait for as long as it takes
|
|
287
|
+
# @param timeout [Integer, Float, nil] the seconds the deadline was set from, which the error names
|
|
288
|
+
# @return [void]
|
|
289
|
+
# @raise [MediaProcessingTimeout] if the check would come after the deadline
|
|
290
|
+
# @example Wait before checking the status of media again
|
|
291
|
+
# Uploads::Utils.wait_to_check(status, deadline: Uploads::Utils.seconds_from_now(600), timeout: 600)
|
|
292
|
+
def wait_to_check(status, deadline:, timeout:)
|
|
293
|
+
wait = [status.check_after_secs.to_i, MIN_CHECK_AFTER_SECS].max
|
|
294
|
+
raise MediaProcessingTimeout.new(media: status, timeout:) if deadline && seconds_from_now(wait) > deadline
|
|
295
|
+
|
|
296
|
+
sleep wait
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The time on the monotonic clock some seconds from now, which keeps a deadline
|
|
300
|
+
#
|
|
301
|
+
# The monotonic clock counts from no time in particular, but never goes back, as the time of day does when the
|
|
302
|
+
# clock of the system is set, so the seconds between two of its times are the seconds that passed between them.
|
|
303
|
+
#
|
|
304
|
+
# @api private
|
|
305
|
+
# @param seconds [Integer, Float] the seconds from now
|
|
306
|
+
# @return [Float] the time on the monotonic clock
|
|
307
|
+
# @example The time on the monotonic clock a minute from now
|
|
308
|
+
# Uploads::Utils.seconds_from_now(60) # => 1234.5
|
|
309
|
+
def seconds_from_now(seconds) = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
310
|
+
|
|
311
|
+
# The media category the subtitles endpoint takes, from one given in any form
|
|
312
|
+
#
|
|
313
|
+
# The uploaders take a category as tweet_video, in any case, and the endpoint names it TweetVideo, so both are
|
|
314
|
+
# taken, and any other category raises before a request the endpoint would refuse.
|
|
315
|
+
#
|
|
316
|
+
# @api private
|
|
317
|
+
# @param media_category [String, Symbol] the media category, in any form
|
|
318
|
+
# @return [String] the media category as the subtitles endpoint names it
|
|
319
|
+
# @raise [ArgumentError] if the media category is neither tweet_video nor amplify_video
|
|
320
|
+
# @example The category of subtitles for a video attached to a post
|
|
321
|
+
# Uploads::Utils.subtitled_media_category(:tweet_video) # => "TweetVideo"
|
|
322
|
+
def subtitled_media_category(media_category)
|
|
323
|
+
category = media_category.to_s.downcase
|
|
324
|
+
_, name = SUBTITLED_MEDIA_CATEGORIES.find { |key, value| [key, value.downcase].include?(category) }
|
|
325
|
+
name or raise ArgumentError, "Invalid media_category: #{media_category}. Valid values: #{SUBTITLED_MEDIA_CATEGORIES.keys.join(", ")}"
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
private_constant :Utils
|
|
329
|
+
end
|
|
330
|
+
end
|