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.
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+ require_relative "uploaded_media"
5
+
6
+ module X
7
+ # Error raised when uploaded media is still processing after the time await_processing may wait
8
+ # @api public
9
+ class MediaProcessingTimeout < Uploads::Error
10
+ # The message for the seconds allowed: the error is raised once the check X asks for next would come after them,
11
+ # which is at once when X asks for the first check only after they have passed
12
+ WITHIN_TIMEOUT = "Media processing did not finish within the %s seconds allowed: its next check would come after them"
13
+ private_constant :WITHIN_TIMEOUT
14
+
15
+ # The uploaded media, as the last processing status X reported describes it
16
+ #
17
+ # Its processing_info holds the state and progress of the processing.
18
+ #
19
+ # @api public
20
+ # @return [UploadedMedia, nil] the media, which reads as a Hash, or nil if none was given
21
+ # @example Read how far processing got
22
+ # error.media.dig("processing_info", "progress_percent") # => 42
23
+ # @example Wait for the media again later
24
+ # rescue X::MediaProcessingTimeout => e
25
+ # client.await_media_processing(e.media)
26
+ attr_reader :media
27
+
28
+ # The seconds await_processing was allowed to wait
29
+ # @api public
30
+ # @return [Integer, Float, nil] the seconds, or nil if none were given
31
+ # @example Read how long processing was awaited
32
+ # error.timeout # => 600
33
+ attr_reader :timeout
34
+
35
+ # Initialize the error with the last status and the time that was allowed
36
+ #
37
+ # The message is the one given, or else names the time that was allowed, when one was given.
38
+ #
39
+ # @api public
40
+ # @param message [String, nil] the message, or nil for one that names the time allowed
41
+ # @param media [UploadedMedia, Hash{String => Object}, nil] the media, as the last processing status X reported
42
+ # describes it, a Hash of which is held as uploaded media
43
+ # @param timeout [Integer, Float, nil] the seconds await_processing was allowed to wait
44
+ # @return [MediaProcessingTimeout] a new error
45
+ # @example Raise the error after ten minutes
46
+ # raise X::MediaProcessingTimeout.new(media: status, timeout: 600)
47
+ # @example Raise the error with a message of its own, as a test stub may
48
+ # raise X::MediaProcessingTimeout, "Still processing"
49
+ def initialize(message = nil, media: nil, timeout: nil)
50
+ @media = media.is_a?(Hash) ? UploadedMedia.new(media) : media
51
+ @timeout = timeout
52
+ super(message || (timeout ? format(WITHIN_TIMEOUT, timeout) : "Media processing did not finish"))
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,565 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "x/core"
5
+ require_relative "alt_text_failed"
6
+ require_relative "media_processing_check_failed"
7
+ require_relative "chunks"
8
+ require_relative "gif"
9
+ require_relative "invalid_media_type"
10
+ require_relative "json_classes"
11
+ require_relative "media_processing_failed"
12
+ require_relative "media_processing_timeout"
13
+ require_relative "metadata"
14
+ require_relative "signature"
15
+ require_relative "source"
16
+ require_relative "uploaded_media"
17
+ require_relative "utils"
18
+ require_relative "validator"
19
+ require_relative "version"
20
+
21
+ module X
22
+ module Uploads
23
+ # Uploads media files to the X API
24
+ #
25
+ # Its methods can be called on the module, or on an instance of a class that includes it, which gains its public
26
+ # methods alone: what they call belongs to modules of its own, or is called on the module, as upload calls
27
+ # await_processing, so no method the class defines, under any name, can change an upload.
28
+ #
29
+ # @api public
30
+ module MediaUpload
31
+ extend self
32
+
33
+ # Number of bytes per megabyte
34
+ BYTES_PER_MB = Validator::BYTES_PER_MB
35
+ # Greatest number of bytes the API takes in a single upload request, above which an animated GIF, which it
36
+ # takes in chunks of up to 15 MB, uploads in chunks
37
+ MAX_SIMPLE_UPLOAD_BYTES = Validator::MAX_SIMPLE_UPLOAD_BYTES
38
+ # Media category constants
39
+ AMPLIFY_VIDEO, DM_GIF, DM_IMAGE, DM_VIDEO, SUBTITLES, TWEET_GIF, TWEET_IMAGE, TWEET_VIDEO = Validator::MEDIA_CATEGORIES
40
+ # Supported MIME types: every media type the API documents for an upload of a media category it documents. The
41
+ # initialization of an upload also takes the types of a glTF or USDZ 3D model, model/gltf-binary and
42
+ # model/vnd.usdz+zip, but no media category takes a model, so they are left out, and a file of either raises
43
+ # before a request, rather than be sent as an image X refuses once it is uploaded.
44
+ MIME_TYPES = %w[image/bmp image/gif image/jpeg image/pjpeg image/png image/tiff image/webp text/srt text/vtt
45
+ video/mp2t video/mp4 video/quicktime video/webm].map(&:freeze).freeze
46
+ # MIME type constants
47
+ BMP_MIME_TYPE, GIF_MIME_TYPE, JPEG_MIME_TYPE, PJPEG_MIME_TYPE, PNG_MIME_TYPE, TIFF_MIME_TYPE, WEBP_MIME_TYPE,
48
+ SUBRIP_MIME_TYPE, WEBVTT_MIME_TYPE, MPEG_TS_MIME_TYPE, MP4_MIME_TYPE, QUICKTIME_MIME_TYPE, WEBM_MIME_TYPE = MIME_TYPES
49
+ # Mapping of file extensions to MIME types
50
+ MIME_TYPE_MAP = {
51
+ "bmp" => BMP_MIME_TYPE, "gif" => GIF_MIME_TYPE, "jpg" => JPEG_MIME_TYPE, "jpeg" => JPEG_MIME_TYPE, "pjp" => PJPEG_MIME_TYPE,
52
+ "pjpeg" => PJPEG_MIME_TYPE, "png" => PNG_MIME_TYPE, "tif" => TIFF_MIME_TYPE, "tiff" => TIFF_MIME_TYPE, "webp" => WEBP_MIME_TYPE,
53
+ "srt" => SUBRIP_MIME_TYPE, "vtt" => WEBVTT_MIME_TYPE,
54
+ "m2ts" => MPEG_TS_MIME_TYPE, "mts" => MPEG_TS_MIME_TYPE, "ts" => MPEG_TS_MIME_TYPE, "m4v" => MP4_MIME_TYPE,
55
+ "mp4" => MP4_MIME_TYPE, "mov" => QUICKTIME_MIME_TYPE, "qt" => QUICKTIME_MIME_TYPE, "webm" => WEBM_MIME_TYPE
56
+ }.freeze
57
+ # MIME types of the images the API takes, which an image category takes every one of: a GIF of a single frame is
58
+ # an image, since X processes only animated GIFs as GIFs
59
+ IMAGE_MIME_TYPES = [BMP_MIME_TYPE, GIF_MIME_TYPE, JPEG_MIME_TYPE, PJPEG_MIME_TYPE, PNG_MIME_TYPE, TIFF_MIME_TYPE, WEBP_MIME_TYPE].freeze
60
+ # MIME types of the videos the API takes, the first of which a video of no known type is uploaded as
61
+ VIDEO_MIME_TYPES = [MP4_MIME_TYPE, QUICKTIME_MIME_TYPE, WEBM_MIME_TYPE, MPEG_TS_MIME_TYPE].freeze
62
+ # MIME types of the subtitles the API takes, the first of which subtitles of no known type are uploaded as
63
+ SUBTITLES_MIME_TYPES = [SUBRIP_MIME_TYPE, WEBVTT_MIME_TYPE].freeze
64
+ # MIME types every file of which begins with a signature {Signature} reads, so that a file named as one that
65
+ # begins with none is not what its name says, such as TypeScript named .ts, which MPEG-TS is named too. An MP4
66
+ # or QuickTime file may begin with a brand, or an atom, that names no type, and SubRip subtitles with nothing a
67
+ # text file could not, so a file named as one of those is typed by its name.
68
+ SIGNED_MIME_TYPES = [BMP_MIME_TYPE, GIF_MIME_TYPE, JPEG_MIME_TYPE, PJPEG_MIME_TYPE, PNG_MIME_TYPE, TIFF_MIME_TYPE,
69
+ WEBP_MIME_TYPE, WEBVTT_MIME_TYPE, MPEG_TS_MIME_TYPE].freeze
70
+ # Default number of seconds await_processing waits for processing to finish before it gives up
71
+ DEFAULT_PROCESSING_TIMEOUT = 600
72
+ # Default number of chunks uploaded at once
73
+ DEFAULT_CONCURRENCY = 4
74
+ # Default number of bytes in each chunk of an upload in chunks, 4 megabytes, unless the media needs larger ones
75
+ # to fit the segments the API numbers
76
+ DEFAULT_CHUNK_SIZE = Validator::DEFAULT_CHUNK
77
+ # Greatest number of chunks uploaded at once, each of which holds a chunk of up to 5 megabytes and a connection
78
+ MAX_CONCURRENCY = Validator::MAX_CONCURRENCY
79
+ # The command that asks the upload endpoint how far the processing of media has got
80
+ STATUS_COMMAND = "STATUS"
81
+ # Media categories that are uploaded in chunks and processed after the upload
82
+ VIDEO_CATEGORIES = [AMPLIFY_VIDEO, DM_VIDEO, TWEET_VIDEO].freeze
83
+ # Media categories uploaded in chunks whatever their size: videos and subtitles, which a single request takes too,
84
+ # but without the media type an upload in chunks sends, only up to MAX_SIMPLE_UPLOAD_BYTES, and not as
85
+ # amplify_video
86
+ CHUNKED_CATEGORIES = [*VIDEO_CATEGORIES, SUBTITLES].freeze
87
+ # Media categories of images, which upload in a single request unless they are shared
88
+ IMAGE_CATEGORIES = [DM_IMAGE, TWEET_IMAGE].freeze
89
+ # Media categories of animated GIFs, which upload in chunks only when a single request cannot take them
90
+ GIF_CATEGORIES = [DM_GIF, TWEET_GIF].freeze
91
+ # Greatest number of bytes the API takes of a GIF, which one is read no further than
92
+ MAX_GIF_BYTES = Validator::MAX_MEDIA_BYTES.fetch(TWEET_GIF)
93
+ # Mapping of MIME types to the media categories of posts; any other type is an image
94
+ TYPE_CATEGORIES = {
95
+ GIF_MIME_TYPE => TWEET_GIF, MPEG_TS_MIME_TYPE => TWEET_VIDEO, MP4_MIME_TYPE => TWEET_VIDEO,
96
+ QUICKTIME_MIME_TYPE => TWEET_VIDEO, WEBM_MIME_TYPE => TWEET_VIDEO, SUBRIP_MIME_TYPE => SUBTITLES,
97
+ WEBVTT_MIME_TYPE => SUBTITLES
98
+ }.freeze
99
+ # The containers of the video file extensions the API documents no media type for, by extension: a video the
100
+ # API takes is MP4, QuickTime, WebM, or MPEG-TS, so a file of one of these uploads only as the type its signature
101
+ # names, such as a WebM video named .mkv, and raises before a request otherwise, rather than be sent as a type
102
+ # it is not
103
+ UNDOCUMENTED_VIDEOS = {"avi" => "AVI", "mkv" => "Matroska"}.freeze
104
+ # The formats of the 3D model file extensions no media category the API documents takes, by extension: the
105
+ # initialization of an upload takes their media types, but X attaches images, GIFs, and videos to a post, so a
106
+ # file of one raises before a request, rather than be sent as an image
107
+ UNDOCUMENTED_MODELS = {"glb" => "glTF", "usdz" => "USDZ"}.freeze
108
+ # Mapping of media categories to the MIME types they take; a video or subtitles category uploads media of no
109
+ # known type as its first, since an MP4 video or SubRip subtitles may begin with no signature that names them
110
+ CATEGORY_MIME_TYPES = {
111
+ TWEET_IMAGE => IMAGE_MIME_TYPES, DM_IMAGE => IMAGE_MIME_TYPES, TWEET_GIF => [GIF_MIME_TYPE], DM_GIF => [GIF_MIME_TYPE],
112
+ TWEET_VIDEO => VIDEO_MIME_TYPES, DM_VIDEO => VIDEO_MIME_TYPES, AMPLIFY_VIDEO => VIDEO_MIME_TYPES,
113
+ SUBTITLES => SUBTITLES_MIME_TYPES
114
+ }.freeze
115
+ private_constant :MIME_TYPES, :BMP_MIME_TYPE, :GIF_MIME_TYPE, :JPEG_MIME_TYPE, :PJPEG_MIME_TYPE, :PNG_MIME_TYPE,
116
+ :TIFF_MIME_TYPE, :WEBP_MIME_TYPE, :SUBRIP_MIME_TYPE, :WEBVTT_MIME_TYPE, :MPEG_TS_MIME_TYPE, :MP4_MIME_TYPE,
117
+ :QUICKTIME_MIME_TYPE, :WEBM_MIME_TYPE, :MIME_TYPE_MAP, :IMAGE_MIME_TYPES, :VIDEO_MIME_TYPES, :SUBTITLES_MIME_TYPES,
118
+ :SIGNED_MIME_TYPES, :STATUS_COMMAND, :VIDEO_CATEGORIES, :CHUNKED_CATEGORIES, :IMAGE_CATEGORIES, :GIF_CATEGORIES,
119
+ :TYPE_CATEGORIES, :CATEGORY_MIME_TYPES, :MAX_GIF_BYTES, :UNDOCUMENTED_VIDEOS, :UNDOCUMENTED_MODELS, :BYTES_PER_MB, :MAX_SIMPLE_UPLOAD_BYTES
120
+
121
+ # Upload media, in chunks when the API needs them, awaiting any processing
122
+ #
123
+ # The media is a path, or an IO open on it, which {Source} says how each of is read.
124
+ #
125
+ # A video and subtitles upload in chunks, as does an animated GIF that a single request cannot take. Every
126
+ # argument is validated before the first request, so that no media is uploaded for an upload that cannot
127
+ # finish.
128
+ #
129
+ # An image, and a GIF that a single request takes, upload in a single request, which takes no chunks and no
130
+ # media type, so chunk_size, concurrency, and media_type are ignored for them: a chunk_size or a concurrency
131
+ # that is not valid still raises, but none is sent. Media given shared: true uploads in chunks, and uses all
132
+ # three. Upload with {chunked_upload} to send an image in chunks too.
133
+ #
134
+ # Media the response of the upload says is still processing is awaited as {await_processing!} awaits it. Media
135
+ # the response says has already failed to process raises MediaProcessingFailed with that response, and media it
136
+ # says has finished is returned as it is, without a check of its status.
137
+ #
138
+ # The chunks of an upload in chunks are sent by threads of their own, as many as the concurrency, so the
139
+ # on_response of the client runs on those threads for the response of each chunk, and a hook that reads state
140
+ # kept for the thread that called, such as a Rails CurrentAttributes or a logger of its own, reads that of
141
+ # another thread.
142
+ #
143
+ # Each chunk is a request of its own, which a rate limit can refuse, and a chunk refused raises
144
+ # ChunkedUploadFailed, since the client retries a request refused for a rate limit only max_rate_limit_retries
145
+ # times, which is 0 by default. A large video, uploaded in many chunks, should be uploaded with a client whose
146
+ # max_rate_limit_retries is set, such as X::Client.new(max_rate_limit_retries: 3), so that a rate limit is waited
147
+ # out, up to the max_rate_limit_wait of the client, rather than fail the upload.
148
+ #
149
+ # @api public
150
+ # @param media [String, Pathname, IO, StringIO] the path to the media to upload, or an IO open on it
151
+ # @param client [Client] the X API client
152
+ # @param media_category [String, Symbol, nil] the media category, in any case, inferred when nil from the bytes
153
+ # the media begins with, or else from the name of its file
154
+ # @param alt_text [String, nil] alt text describing the media, for people who cannot see it, of 1 to 1,000 characters
155
+ # @param processing_timeout [Integer, Float, nil] the seconds to wait for media, such as a video or an animated
156
+ # GIF, to process, of at least 0, from when it is uploaded, as {await_processing} counts them, or nil to wait
157
+ # for as long as processing takes
158
+ # @param media_type [String, nil] the MIME type of media uploaded in chunks, inferred from the media and
159
+ # category when nil; an upload in a single request sends no type, since the API types the media itself, so
160
+ # one given for an image is not sent
161
+ # @param chunk_size [Integer, nil] the size of each chunk of media uploaded in chunks, in bytes, of at most
162
+ # 5,242,880, the 5 megabytes the API takes in a segment, or nil for DEFAULT_CHUNK_SIZE, 4,194,304 bytes, or as
163
+ # much more as the media needs to fit the segments the API numbers
164
+ # @param concurrency [Integer] the number of chunks uploaded at once, of 1 to MAX_CONCURRENCY
165
+ # @param shared [Boolean, nil] whether the media is shared, so that it can be sent in more than one direct
166
+ # message, or nil to leave it to the API; media
167
+ # that is shared uploads in chunks, since a single request takes no shared
168
+ # @param additional_owners [Array<Integer, String>, nil] the identifiers of the users, other than the one who
169
+ # uploads it, who may use the media, or nil for none
170
+ # @return [UploadedMedia] the uploaded media, which holds the upload response, or the processing status of
171
+ # media that X processes
172
+ # @raise [ArgumentError] if the media is neither a path nor an IO, or is a String that holds a NUL byte or a
173
+ # line break, as the contents of media given in place of its path do
174
+ # @raise [InvalidMedia] if the file does not exist
175
+ # @raise [InvalidMedia] if the media cannot be read, or is empty, which holds nothing to upload
176
+ # @raise [InvalidMedia] if the media is larger than the API takes of its category, whatever the account: 5
177
+ # megabytes of an image, 15 of a GIF, and one of subtitles, or larger than the 16 gigabytes it takes of any
178
+ # @raise [ArgumentError] if the media category is invalid, the alt text is empty or longer than the API takes,
179
+ # the chunk size is not a positive Integer, is larger than a segment the API takes, or would need more
180
+ # segments than the API numbers, the concurrency is not 1 to MAX_CONCURRENCY, the processing timeout is
181
+ # neither nil nor a finite number of seconds of at least 0, shared is neither true, false, nor nil, or
182
+ # additional_owners is neither nil nor an Array of at least one user identifier
183
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for a request
184
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read and
185
+ # leaves out the value: the request
186
+ # that initializes an upload in chunks, or the single request of any other, raises it as it is, and a later
187
+ # request that opens a connection fails with it as the cause of the ChunkedUploadFailed,
188
+ # MediaProcessingCheckFailed, or AltTextFailed it raises
189
+ # @raise [InvalidMediaType] if no media category is given for media whose type neither its bytes nor the name of
190
+ # its file names, if media uploaded in chunks is given no media type and none can be inferred, if the category does not
191
+ # take the type of the media, such as an MP4 video uploaded as a GIF, or if the file is named as a type every
192
+ # file of which begins with a signature, such as a PNG, and does not begin with it
193
+ # @raise [MissingMediaData] if a response of the upload holds no media, or carries no body at all
194
+ # @raise [ChunkedUploadFailed] if media uploaded in chunks is initialized, but a chunk cannot be appended, or it
195
+ # cannot be finalized, with the media it initialized
196
+ # @raise [MediaProcessingFailed] if the media fails to process
197
+ # @raise [MediaProcessingTimeout] if the media is still processing once processing_timeout seconds would pass,
198
+ # in whatever state, one X does not document among them
199
+ # @raise [MediaProcessingCheckFailed] if the media is uploaded, but a check of its processing fails, as when the
200
+ # API answers it with an error, with the media it uploaded
201
+ # @raise [AltTextFailed] if the media is uploaded, but its alt text cannot be added, with the media it uploaded
202
+ # @raise [TokenReportFailed] if save_tokens raises for the tokens of a refresh a request of the upload made, with
203
+ # the tokens, rather than the media, whatever the upload had done by then
204
+ # @example Upload an image
205
+ # Uploads::MediaUpload.upload("image.png", client: client)
206
+ # @example Upload an image with alt text
207
+ # Uploads::MediaUpload.upload("cat.jpg", client: client, alt_text: "A cat asleep on a keyboard")
208
+ # @example Upload a video and wait until it can be attached to a post
209
+ # Uploads::MediaUpload.upload("video.mp4", client: client)
210
+ # @example Upload an image held in memory, whose category its signature names
211
+ # Uploads::MediaUpload.upload(StringIO.new(png), client: client)
212
+ # @example Upload an image another account may post too
213
+ # Uploads::MediaUpload.upload("cat.jpg", client: client, additional_owners: [7_505_382])
214
+ def upload(media, client:, media_category: nil, alt_text: nil, processing_timeout: DEFAULT_PROCESSING_TIMEOUT,
215
+ media_type: nil, chunk_size: nil, concurrency: DEFAULT_CONCURRENCY, shared: nil, additional_owners: nil)
216
+ source = Source.for(media)
217
+ media_category = Validator.validate_upload!(source, media_category, alt_text:, chunk_size:, concurrency:, processing_timeout:, shared:, additional_owners:) { Inference.infer_media_category(source) }
218
+ uploaded = if shared || Inference.chunked_upload?(source, media_category)
219
+ Chunks.upload(client:, source:, media_type: media_type || Inference.infer_media_type(source, media_category), media_category:, chunk_size:, concurrency:, shared:, additional_owners:)
220
+ else
221
+ Utils.single_request(client, Inference.single_request!(source, media_category), media_category, additional_owners:)
222
+ end
223
+ uploaded = Utils.processed!(uploaded.processing? ? MediaProcessingCheckFailed.__send__(:keeping, uploaded) { MediaUpload.await_processing(uploaded, client:, processing_timeout:) } : uploaded)
224
+ AltTextFailed.__send__(:keeping, uploaded) { Metadata.add_alt_text(uploaded, alt_text, client:) } unless alt_text.nil?
225
+ uploaded
226
+ end
227
+
228
+ # Perform a chunked upload for large files
229
+ #
230
+ # It is the way to upload media without waiting for X to process it: {upload} waits for the processing of media
231
+ # X processes, such as a video, and this returns once the upload is finalized, so that the caller can go on while
232
+ # X processes a long video, and wait for it with {await_processing} or {await_processing!} when it needs it. It
233
+ # uploads in chunks whatever the media, an image as well.
234
+ #
235
+ # The chunks of an upload in chunks are sent by threads of their own, as many as the concurrency, so the
236
+ # on_response of the client runs on those threads for the response of each chunk, and a hook that reads state
237
+ # kept for the thread that called, such as a Rails CurrentAttributes or a logger of its own, reads that of
238
+ # another thread.
239
+ #
240
+ # Each chunk is a request of its own, which a rate limit can refuse, and a chunk refused raises
241
+ # ChunkedUploadFailed, since the client retries a request refused for a rate limit only max_rate_limit_retries
242
+ # times, which is 0 by default. A large video, uploaded in many chunks, should be uploaded with a client whose
243
+ # max_rate_limit_retries is set, such as X::Client.new(max_rate_limit_retries: 3), so that a rate limit is waited
244
+ # out, up to the max_rate_limit_wait of the client, rather than fail the upload.
245
+ #
246
+ # @api public
247
+ # @param media [String, Pathname, IO, StringIO] the path to the media to upload, or an IO open on it
248
+ # @param client [Client] the X API client
249
+ # @param media_category [String, Symbol, nil] the media category, in any case, inferred from the media when nil
250
+ # @param media_type [String, nil] the MIME type of the media, sent as it is given, or inferred from the media and
251
+ # category when nil
252
+ # @param chunk_size [Integer, nil] the size of each chunk in bytes, of at most 5,242,880, the 5 megabytes the API
253
+ # takes in a segment, or nil for DEFAULT_CHUNK_SIZE, 4,194,304 bytes, or as much more as the media needs to fit
254
+ # the segments the API numbers
255
+ # @param concurrency [Integer] the number of chunks uploaded at once, of 1 to MAX_CONCURRENCY
256
+ # @param shared [Boolean, nil] whether the media is shared, so that it can be sent in more than one direct
257
+ # message, or nil to leave it to the API
258
+ # @param additional_owners [Array<Integer, String>, nil] the identifiers of the users, other than the one who
259
+ # uploads it, who may use the media, or nil for none
260
+ # @return [UploadedMedia] the uploaded media, which holds the upload response
261
+ # @raise [ArgumentError] if the media is neither a path nor an IO, or is a String that holds a NUL byte or a
262
+ # line break, as the contents of media given in place of its path do
263
+ # @raise [InvalidMedia] if the file does not exist
264
+ # @raise [InvalidMedia] if the media cannot be read, or is empty, which holds nothing to upload
265
+ # @raise [InvalidMedia] if the media is larger than the API takes of its category, whatever the account: 5
266
+ # megabytes of an image, 15 of a GIF, and one of subtitles, or larger than the 16 gigabytes it takes of any
267
+ # @raise [ArgumentError] if the media category is invalid, the chunk size is not a positive Integer, is
268
+ # larger than a segment the API takes, or would need more segments than the API numbers, the concurrency is not
269
+ # 1 to MAX_CONCURRENCY, shared is neither true, false, nor nil, or additional_owners is neither nil nor an
270
+ # Array of at least one user identifier
271
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for a request
272
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read and
273
+ # leaves out the value: the request
274
+ # that initializes the upload raises it as it is, and a chunk or the finalize that opens a connection fails
275
+ # with it as the cause of the ChunkedUploadFailed it raises
276
+ # @raise [InvalidMediaType] if no media type is given and none can be inferred, or the one the media is, read
277
+ # from its bytes or else from the name of its file, is not one the category takes
278
+ # @raise [MissingMediaData] if the response that initializes the upload holds no media to append the chunks to
279
+ # @raise [ChunkedUploadFailed] if the upload is initialized, but a chunk cannot be appended, or it cannot be
280
+ # finalized, or the response that finalizes it holds no media or carries no body at all, with the media it
281
+ # initialized, and the error that failed it as the cause
282
+ # @raise [TokenReportFailed] if save_tokens raises for the tokens of a refresh a request of the upload made, with
283
+ # the tokens, rather than the media, whatever the upload had done by then
284
+ # @example Upload a large video
285
+ # Uploads::MediaUpload.chunked_upload("video.mp4", client: client)
286
+ def chunked_upload(media, client:, media_category: nil, media_type: nil, chunk_size: nil, concurrency: DEFAULT_CONCURRENCY,
287
+ shared: nil, additional_owners: nil)
288
+ source = Source.for(media)
289
+ Validator.validate_sharing!(shared, additional_owners)
290
+ Validator.validate_source!(source)
291
+ media_category = Validator.validate_media_category!(media_category || Inference.infer_media_category(source))
292
+ Validator.validate_size!(source, media_category)
293
+ Validator.validate_chunks!(chunk_size:, concurrency:)
294
+ Chunks.upload(client:, source:, media_type: media_type || Inference.infer_media_type(source, media_category), media_category:, chunk_size:, concurrency:, shared:, additional_owners:)
295
+ end
296
+
297
+ # Wait for media processing to complete
298
+ #
299
+ # Media that already says its processing has ended, in success or failure, or that holds an upload response
300
+ # which names no processing, as that of an image does, is returned as it is, without a request, since a check
301
+ # would tell no more than the media holds; await_processing! raises for media that says it failed, without a
302
+ # request too.
303
+ #
304
+ # Before each check it waits as long as X asked, and at least a second, which is how long it waits when X asks
305
+ # for no wait: media an upload returned, or the Hash of its response, which says how long to wait before the
306
+ # first check, is not checked until then, and media given as its identifier, or its identifier and media key
307
+ # alone, which say nothing of its processing, is checked at once.
308
+ #
309
+ # The processing timeout is a deadline, the seconds from when it is called, measured on the monotonic clock, so
310
+ # that it counts the time each check takes, with any wait for a rate limit and any retry the client makes, as
311
+ # well as the waits between them. It gives up once the next check X asks for would come after the deadline,
312
+ # rather than sleep past it, or check before X asks. A check under way at the deadline is let finish, and its
313
+ # status returned if processing has finished, so it can return that much after the deadline.
314
+ #
315
+ # It waits until the processing ends, which it does once it has succeeded or failed alone, so a status whose
316
+ # processing names no state, or a state X does not document, is still processing, and is checked again, until
317
+ # the deadline, as one pending or in progress is: a state X adds between those it documents is waited through,
318
+ # rather than end the wait, or fail the upload, in every release that does not know it.
319
+ #
320
+ # @api public
321
+ # @param media [UploadedMedia, Hash, #media_key, String, Integer] the uploaded media, media that has a media key,
322
+ # such as X::Media, the media key, or the media identifier
323
+ # @param client [Client] the X API client
324
+ # @param processing_timeout [Integer, Float, nil] the seconds from now to wait for processing to finish, checks
325
+ # and all, before giving up, or nil to wait for as long as processing takes
326
+ # @return [UploadedMedia] the uploaded media, which holds the processing status, or the media given, as uploaded
327
+ # media, if its processing has already ended
328
+ # @raise [ArgumentError] if the processing timeout is neither a finite number of seconds of at least 0 nor nil
329
+ # @raise [ArgumentError] if the media given is nil, holds no identifier, or is neither media, a media key, nor a
330
+ # media identifier, or its media key names none
331
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
332
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read and
333
+ # leaves out the value
334
+ # @raise [MissingMediaData] if a status response holds no media or carries no body at all
335
+ # @raise [MediaProcessingTimeout] if the media is still processing once the next check would pass the deadline
336
+ # @example Wait for processing
337
+ # Uploads::MediaUpload.await_processing(media, client: client)
338
+ # @example Wait for the processing of media known by its identifier
339
+ # Uploads::MediaUpload.await_processing("1880028106020515840", client: client)
340
+ # @example Wait up to half an hour for a long video
341
+ # Uploads::MediaUpload.await_processing(media, client: client, processing_timeout: 1800)
342
+ def await_processing(media, client:, processing_timeout: DEFAULT_PROCESSING_TIMEOUT)
343
+ Validator.validate_processing_timeout!(processing_timeout)
344
+ uploaded = Utils.uploaded_media(media)
345
+ return uploaded if uploaded.ready? || uploaded.failed?
346
+
347
+ deadline, media_id, pending = processing_timeout&.then { |seconds| Utils.seconds_from_now(seconds) }, Utils.media_id(uploaded), (uploaded if uploaded.processing?)
348
+ Kernel.loop do
349
+ Utils.wait_to_check(pending, deadline:, timeout: processing_timeout) if pending
350
+ status = UploadedMedia.new(Utils.media_data(client.get("media/upload", params: {command: STATUS_COMMAND, media_id:}, **JSON_CLASSES), "of the status check"))
351
+ return status unless status.processing?
352
+
353
+ pending = status
354
+ end
355
+ end
356
+
357
+ # Wait for media processing and raise on failure
358
+ #
359
+ # @api public
360
+ # @param media [UploadedMedia, Hash, #media_key, String, Integer] the uploaded media, media that has a media key,
361
+ # such as X::Media, the media key, or the media identifier
362
+ # @param client [Client] the X API client
363
+ # @param processing_timeout [Integer, Float, nil] the seconds from now to wait for processing to finish, checks
364
+ # and all, before giving up, as {await_processing} counts them, or nil to wait for as long as processing takes
365
+ # @return [UploadedMedia] the uploaded media, which holds the processing status, or the media given, as uploaded
366
+ # media, if its processing has already succeeded
367
+ # @raise [ArgumentError] if the processing timeout is neither a finite number of seconds of at least 0 nor nil
368
+ # @raise [ArgumentError] if the media given is nil, holds no identifier, or is neither media, a media key, nor a
369
+ # media identifier, or its media key names none
370
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
371
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read and
372
+ # leaves out the value
373
+ # @raise [MissingMediaData] if a status response holds no media or carries no body at all
374
+ # @raise [MediaProcessingFailed] if media processing failed, with the status X reported, or the media given,
375
+ # without a request, if it already says its processing failed
376
+ # @raise [MediaProcessingTimeout] if the media is still processing once the next check would pass the deadline
377
+ # @example Wait for processing with error handling
378
+ # Uploads::MediaUpload.await_processing!(media, client: client)
379
+ def await_processing!(media, client:, processing_timeout: DEFAULT_PROCESSING_TIMEOUT)
380
+ Utils.processed!(MediaUpload.await_processing(media, client:, processing_timeout:))
381
+ end
382
+
383
+ # Infers how media uploads: in chunks or whole, in which category, and as which type
384
+ #
385
+ # Internal to x-uploads: the methods of MediaUpload decide with it how to send media, by rules that follow
386
+ # what the API takes and X processes, so that they can change within 1.x as those do. It is a module of its own,
387
+ # rather than private methods of MediaUpload, so that a class that includes MediaUpload gains none of them, and
388
+ # no method the class defines under the same name changes an upload.
389
+ #
390
+ # @api private
391
+ module Inference
392
+ extend self
393
+
394
+ # Check whether a file uploads in chunks rather than in a single request
395
+ #
396
+ # A video and subtitles upload in chunks whatever their size. The API takes them in a single request too, but
397
+ # one that sends no media type, where an upload in chunks sends the type read from the media, such as WebVTT
398
+ # rather than SubRip subtitles, or the media_type given, and takes no more than MAX_SIMPLE_UPLOAD_BYTES, nor
399
+ # an amplify_video at all, so every video and subtitles upload the same way, whatever their size and category.
400
+ # An animated GIF uploads in chunks once it is larger than MAX_SIMPLE_UPLOAD_BYTES, which a single request
401
+ # takes no more of; the API takes a GIF of up to 15 MB in chunks. An image uploads in a single request.
402
+ #
403
+ # upload decides with it how to send media.
404
+ #
405
+ # @api private
406
+ # @param media [String, Pathname, IO, StringIO] the path to the media to upload, or an IO open on it
407
+ # @param media_category [String, Symbol] the media category, in any case
408
+ # @return [Boolean] true if the media uploads in chunks
409
+ # @raise [InvalidMedia] if the system refuses to read the size of a GIF, as it does that of a file that does
410
+ # not exist
411
+ # @example Check whether a large animated GIF uploads in chunks
412
+ # Inference.chunked_upload?("cat.gif", "tweet_gif") # => true
413
+ def chunked_upload?(media, media_category)
414
+ category = media_category.to_s.downcase
415
+ CHUNKED_CATEGORIES.include?(category) || (GIF_CATEGORIES.include?(category) && Source.for(media).size > MAX_SIMPLE_UPLOAD_BYTES)
416
+ end
417
+
418
+ # Infer the media category of a post attachment from the media
419
+ #
420
+ # Media is categorized by its type, which {media_type_of} reads from the bytes it begins with, or else from the
421
+ # extension of the name of its file, so that a file named as what it is not is categorized as what it is. A
422
+ # GIF with a single frame is an image, since X processes only animated GIFs as GIFs.
423
+ #
424
+ # upload infers the category of media it is given none for with it.
425
+ #
426
+ # @api private
427
+ # @param media [String, Pathname, IO, StringIO] the path to the media, or an IO open on it
428
+ # @return [String] tweet_gif, tweet_video for MP4, QuickTime, WebM, or MPEG-TS, subtitles for SubRip or WebVTT, or tweet_image
429
+ # @raise [InvalidMediaType] if neither the bytes of the media nor the name of its file names its type, or its
430
+ # file is named as a type whose signature it does not begin with
431
+ # @example Infer the category of a video
432
+ # Inference.infer_media_category("cat.mp4") # => "tweet_video"
433
+ # @example Infer the category of an animated GIF held in memory
434
+ # Inference.infer_media_category(StringIO.new(gif)) # => "tweet_gif"
435
+ def infer_media_category(media)
436
+ source = Source.for(media)
437
+ documented!(source)
438
+ type = media_type_of(source) or raise InvalidMediaType, "unable to determine the media type of #{source.description}: pass media_category"
439
+ category = TYPE_CATEGORIES.fetch(type, TWEET_IMAGE)
440
+ # A GIF of a single frame is an image, which its category is read again as. A GIF larger than the API takes
441
+ # of any GIF is not read, since it is refused whether it is animated or not, and reading it would hold it all
442
+ still = category.eql?(TWEET_GIF) && source.readable? && source.size <= MAX_GIF_BYTES && !Gif.animated?(source)
443
+ still ? TWEET_IMAGE : category
444
+ end
445
+
446
+ # Infer the media type from the media and its category
447
+ #
448
+ # Media is uploaded as its type, which {media_type_of} reads, when the category takes that type, and raises
449
+ # when it does not, rather than be sent as a type it is not, such as an MP4 video as a GIF. Media whose type
450
+ # cannot be read is uploaded as the first type of a video or subtitles category, MP4 or SubRip, which may
451
+ # begin with no signature that names them, and as JPEG for an image category, which X takes images, such as
452
+ # HEIC photos, no signature here names under, as a single request sends them untyped; it raises for a GIF
453
+ # category, since every GIF begins with its signature.
454
+ #
455
+ # A chunked upload infers the type of media it is given none for with it.
456
+ #
457
+ # @api private
458
+ # @param media [String, Pathname, IO, StringIO] the path to the media, or an IO open on it
459
+ # @param media_category [String, Symbol] the media category, in any case
460
+ # @return [String] the inferred MIME type
461
+ # @raise [InvalidMediaType] if the category does not take the type of the media, or the MIME type cannot be determined
462
+ # @example Inference.infer_media_type("image.png", "tweet_image") #=> "image/png"
463
+ # @example Inference.infer_media_type("clip.webm", "tweet_video") #=> "video/webm"
464
+ def infer_media_type(media, media_category)
465
+ source = Source.for(media)
466
+ documented!(source)
467
+ category = media_category.to_s.downcase
468
+ taken_type(source, category) ||
469
+ (JPEG_MIME_TYPE if IMAGE_CATEGORIES.include?(category)) ||
470
+ (CATEGORY_MIME_TYPES.fetch(category).first if CHUNKED_CATEGORIES.include?(category)) ||
471
+ raise(InvalidMediaType, "unable to determine the MIME type of #{source.description}")
472
+ end
473
+
474
+ # Refuse media to upload in a single request as a category that does not take it
475
+ #
476
+ # Media of a type the category does not take raises, as does media of no known type for a GIF category, since
477
+ # every GIF begins with its signature. Media of no known type is sent for an image category, since the API
478
+ # types what a single request sends itself, and takes images, such as HEIC photos, no signature here names.
479
+ #
480
+ # upload checks what it sends in a single request with it, by the name of the file as well as by its bytes,
481
+ # before it reads the whole of the media.
482
+ #
483
+ # @api private
484
+ # @param source [Source] the media
485
+ # @param media_category [String] the media category, in lowercase
486
+ # @return [String] the whole of the media, to send
487
+ # @raise [InvalidMediaType] if the category does not take the media
488
+ def single_request!(source, media_category)
489
+ documented!(source)
490
+ return source.content if taken_type(source, media_category) || !GIF_CATEGORIES.include?(media_category)
491
+
492
+ raise InvalidMediaType, "#{source.description} is not a GIF, which #{media_category} media must be"
493
+ end
494
+
495
+ # The type of media, once its category is known to take it
496
+ #
497
+ # @api private
498
+ # @param source [Source] the media
499
+ # @param media_category [String] the media category, in lowercase
500
+ # @return [String, nil] the MIME type, or nil for media whose type cannot be read
501
+ # @raise [InvalidMediaType] if the category does not take the type of the media
502
+ def taken_type(source, media_category)
503
+ type = media_type_of(source)
504
+ return type if type.nil? || CATEGORY_MIME_TYPES.fetch(media_category).include?(type)
505
+
506
+ raise InvalidMediaType, "#{source.description} is #{type}, which #{media_category} media is not: pass the " \
507
+ "media_category of what it is, or the media_type to send it as to chunked_upload"
508
+ end
509
+
510
+ # The MIME type of media, read from its bytes, or else from the name of its file
511
+ #
512
+ # The bytes name the type when a signature is read from them, whatever the file is named, and the extension of
513
+ # the name names it otherwise, as it does for media that cannot be read. A file named as a type every file of
514
+ # which begins with a signature, such as a PNG, is not that type when it begins with none.
515
+ #
516
+ # @api private
517
+ # @param source [Source] the media
518
+ # @return [String, nil] the MIME type, or nil if neither the bytes nor the name names one
519
+ # @raise [InvalidMediaType] if the file is named as a type whose signature it does not begin with
520
+ def media_type_of(source)
521
+ named = MIME_TYPE_MAP[source.extension]
522
+ return named unless source.readable?
523
+
524
+ sniffed = Signature.media_type(source.sniff)
525
+ return sniffed || named unless sniffed.nil? && SIGNED_MIME_TYPES.include?(named)
526
+
527
+ raise InvalidMediaType, "#{source.description} is named as #{named}, but does not begin with the bytes every " \
528
+ "#{named} file begins with"
529
+ end
530
+
531
+ # Refuse a video of a container the API documents no media type for, or a 3D model
532
+ #
533
+ # An AVI or Matroska file is a video X documents no type for, so it is refused before a request, rather than
534
+ # be sent as a type it is not, unless it begins with the signature of a type the API documents, as a WebM
535
+ # video named .mkv does. Media whose name names no type, such as a StringIO or a Tempfile, is refused too when
536
+ # it begins with the header of Matroska and is not WebM, since a video category would otherwise send it as MP4.
537
+ # A .glb or .usdz file is a 3D model, which no media category takes, so it is refused whatever it holds.
538
+ #
539
+ # @api private
540
+ # @param source [Source] the media
541
+ # @return [void]
542
+ # @raise [InvalidMediaType] if the media is a 3D model, or of such a container and no signature names its type
543
+ def documented!(source)
544
+ model = UNDOCUMENTED_MODELS[source.extension]
545
+ raise InvalidMediaType, "no media category the API documents takes a #{model} 3D model, such as #{source.description}" if model
546
+
547
+ container = UNDOCUMENTED_VIDEOS.fetch(source.extension) { "Matroska" if matroska?(source) }
548
+ return if container.nil? || (source.readable? && Signature.media_type(source.sniff))
549
+
550
+ raise InvalidMediaType, "the API documents no media type for #{container} video, such as #{source.description}: " \
551
+ "convert it to MP4, QuickTime, WebM, or MPEG-TS"
552
+ end
553
+
554
+ # Whether media whose name names no type begins with the header of Matroska
555
+ # @api private
556
+ # @param source [Source] the media
557
+ # @return [Boolean] true if the name of the media names no type and the media is Matroska
558
+ def matroska?(source)
559
+ !MIME_TYPE_MAP.key?(source.extension) && source.readable? && Signature.matroska?(source.sniff)
560
+ end
561
+ end
562
+ private_constant :Inference
563
+ end
564
+ end
565
+ end