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,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "account"
4
+ require_relative "media_upload"
5
+ require_relative "metadata"
6
+ require_relative "utils"
7
+
8
+ module X
9
+ module Uploads
10
+ # The upload methods mixed into a client, each of which calls an uploader with the client
11
+ #
12
+ # The x gem includes it into X::Client. With x-core and x-uploads alone, include it yourself:
13
+ # X::Client.include(X::Uploads::API). Each method passes the object it is included into to an uploader as its
14
+ # client, which is an X::Client, so it belongs in X::Client or a subclass.
15
+ #
16
+ # @api public
17
+ module API
18
+ # Upload media and wait for it to be processed
19
+ #
20
+ # The media is a path, or an IO open on it. Media given as a String or a Pathname is read from the file it names,
21
+ # and media given as a File or a Tempfile through that IO, a chunk at a time, so media of any size uploads
22
+ # without being held in memory; media given as any other IO, such as a StringIO, is read to its end and held.
23
+ #
24
+ # A video or subtitles upload in chunks, which send their media type, as a single request does not. The media
25
+ # category is inferred from the bytes the media begins with, or else from the name of its file, unless
26
+ # media_category says what it is. The chunks are sent by threads of their own, so the on_response of the client
27
+ # runs on those threads for the response of each chunk.
28
+ #
29
+ # An image, and a GIF that a single request takes, upload in a single request, which takes no chunks and no
30
+ # media type, so chunk_size, concurrency, and media_type are ignored for them: a chunk_size or a concurrency
31
+ # that is not valid still raises, but none is sent. Media given shared: true uploads in chunks, and uses all
32
+ # three.
33
+ #
34
+ # Each chunk is a request a rate limit can refuse, which fails the upload with ChunkedUploadFailed unless the
35
+ # client retries it, which it does only max_rate_limit_retries times, 0 by default, so upload a large video with
36
+ # a client whose max_rate_limit_retries is set, such as X::Client.new(max_rate_limit_retries: 3).
37
+ #
38
+ # @api public
39
+ # @param media [String, Pathname, IO, StringIO] the path to the media to upload, or an IO open on it
40
+ # @param options [Hash] the options of {MediaUpload.upload}
41
+ # @option options [String, Symbol, nil] :media_category (nil) the media category, in any case, inferred when nil
42
+ # from the bytes the media begins with, or else from the name of its file
43
+ # @option options [String, nil] :alt_text (nil) alt text describing the media, of 1 to 1,000 characters, added
44
+ # once the media is uploaded and processed, or nil for none
45
+ # @option options [Integer, Float, nil] :processing_timeout (600) the seconds to wait for media that X processes,
46
+ # such as a video, to process, of at least 0, or nil to wait for as long as processing takes
47
+ # @option options [String, nil] :media_type (nil) the MIME type of media uploaded in chunks, inferred from the
48
+ # media and category when nil; ignored for media uploaded in a single request, such as an image
49
+ # @option options [Integer, nil] :chunk_size (nil) the size of each chunk in bytes, of at most 5,242,880, or nil
50
+ # for MediaUpload::DEFAULT_CHUNK_SIZE, or as much more as the media needs; ignored for media uploaded in a
51
+ # single request, such as an image
52
+ # @option options [Integer] :concurrency (4) the number of chunks uploaded at once, of 1 to
53
+ # MediaUpload::MAX_CONCURRENCY; ignored for media uploaded in a single request, such as an image
54
+ # @option options [Boolean, nil] :shared (nil) whether the media can be sent in more than one direct message, or
55
+ # nil to leave it to the API; media that is shared uploads in chunks
56
+ # @option options [Array<Integer, String>, nil] :additional_owners (nil) the identifiers of the users, other than
57
+ # the one who uploads it, who may use the media, or nil for none
58
+ # @return [UploadedMedia] the uploaded media, which holds the upload response, or the processing status of
59
+ # media that X processes
60
+ # @raise [ArgumentError] if the media is neither a path nor an IO, or is a String that holds a NUL byte or a
61
+ # line break, as the contents of media given in place of its path do
62
+ # @raise [InvalidMedia] if the file does not exist
63
+ # @raise [InvalidMedia] if the media cannot be read, or is empty, which holds nothing to upload
64
+ # @raise [InvalidMedia] if the media is larger than the API takes of its category, whatever the account: 5
65
+ # megabytes of an image, 15 of a GIF, and one of subtitles, or larger than the 16 gigabytes it takes of any
66
+ # @raise [ArgumentError] if the media category is invalid, the alt text is empty or longer than the API takes,
67
+ # the chunk size is not a positive Integer, is larger than a segment the API takes, or would need more
68
+ # segments than the API numbers, the concurrency is not 1 to MAX_CONCURRENCY, the processing timeout is
69
+ # neither nil nor a finite number of seconds of at least 0, shared is neither true, false, nor nil, or
70
+ # additional_owners is neither nil nor an Array of at least one user identifier
71
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for a request
72
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read and
73
+ # leaves out the value: the request
74
+ # that initializes an upload in chunks, or the single request of any other, raises it as it is, and a later
75
+ # request that opens a connection fails with it as the cause of the ChunkedUploadFailed,
76
+ # MediaProcessingCheckFailed, or AltTextFailed it raises
77
+ # @raise [InvalidMediaType] if no media category is given for media whose type neither its bytes nor the name of
78
+ # its file names, or the category does not take the type of the media
79
+ # @raise [MissingMediaData] if a response of the upload holds no media, or carries no body at all
80
+ # @raise [ChunkedUploadFailed] if media uploaded in chunks is initialized, but a chunk cannot be appended, or it
81
+ # cannot be finalized, with the media it initialized
82
+ # @raise [MediaProcessingFailed] if media processing failed, with the status X reported
83
+ # @raise [MediaProcessingTimeout] if the media is still processing once the processing timeout would pass
84
+ # @raise [MediaProcessingCheckFailed] if the media is uploaded, but a check of its processing fails, as when the
85
+ # API answers it with an error, with the media it uploaded
86
+ # @raise [AltTextFailed] if the media is uploaded, but its alt text cannot be added, with the media it uploaded
87
+ # @raise [TokenReportFailed] if save_tokens raises for the tokens of a refresh a request of the upload made, with
88
+ # the tokens, rather than the media, whatever the upload had done by then
89
+ # @example Upload an image with alt text and post it
90
+ # media = client.upload_media("cat.jpg", alt_text: "A cat asleep on a keyboard")
91
+ # client.create_post("Look at this cat", media_ids: [media])
92
+ # @example Upload an image held in memory, whose category its signature names
93
+ # client.upload_media(StringIO.new(File.binread("cat.png")))
94
+ # @example Upload media of a category no signature names
95
+ # client.upload_media(StringIO.new(subtitles), media_category: "subtitles")
96
+ def upload_media(media, **options) # steep:ignore DifferentMethodParameterKind
97
+ MediaUpload.upload(media, client: _ = self, **Utils.without_client(options))
98
+ end
99
+
100
+ # Upload media in chunks, without waiting for it to be processed
101
+ #
102
+ # It is the way to upload media without waiting for X to process it: {#upload_media} waits for the processing of
103
+ # media X processes, such as a video, and this does not. It uploads the media as {#upload_media} uploads a video,
104
+ # a chunk at a time, but returns once the upload is finalized, so that the caller can go on while X processes a
105
+ # long video, and wait for it with {#await_media_processing} or {#await_media_processing!} when it needs it. It
106
+ # uploads in chunks whatever the media, an image as well, and adds no alt text. The
107
+ # chunks are sent by threads of their own, so the on_response of the client runs on those threads for the
108
+ # response of each chunk.
109
+ #
110
+ # Each chunk is a request a rate limit can refuse, which fails the upload with ChunkedUploadFailed unless the
111
+ # client retries it, which it does only max_rate_limit_retries times, 0 by default, so upload a large video with
112
+ # a client whose max_rate_limit_retries is set, such as X::Client.new(max_rate_limit_retries: 3).
113
+ #
114
+ # @api public
115
+ # @param media [String, Pathname, IO, StringIO] the path to the media to upload, or an IO open on it
116
+ # @param options [Hash] the options of {MediaUpload.chunked_upload}
117
+ # @option options [String, Symbol, nil] :media_category (nil) the media category, in any case, inferred when nil
118
+ # from the bytes the media begins with, or else from the name of its file
119
+ # @option options [String, nil] :media_type (nil) the MIME type of the media, sent as it is given, or inferred
120
+ # from the media and category when nil
121
+ # @option options [Integer, nil] :chunk_size (nil) the size of each chunk in bytes, of at most 5,242,880, or nil
122
+ # for MediaUpload::DEFAULT_CHUNK_SIZE, or as much more as the media needs
123
+ # @option options [Integer] :concurrency (4) the number of chunks uploaded at once, of 1 to
124
+ # MediaUpload::MAX_CONCURRENCY
125
+ # @option options [Boolean, nil] :shared (nil) whether the media can be sent in more than one direct message, or
126
+ # nil to leave it to the API
127
+ # @option options [Array<Integer, String>, nil] :additional_owners (nil) the identifiers of the users, other than
128
+ # the one who uploads it, who may use the media, or nil for none
129
+ # @return [UploadedMedia] the uploaded media, which holds the response that finalized the upload, and the
130
+ # processing status of media that X processes
131
+ # @raise [ArgumentError] if the media is neither a path nor an IO, or is a String that holds a NUL byte or a
132
+ # line break, as the contents of media given in place of its path do
133
+ # @raise [InvalidMedia] if the file does not exist, the media cannot be read, or is empty, or it is larger than
134
+ # the API takes of its category
135
+ # @raise [ArgumentError] if the media category is invalid, the chunk size is not a positive Integer, is larger
136
+ # than a segment the API takes, or would need more segments than the API numbers, the concurrency is not 1 to
137
+ # MAX_CONCURRENCY, shared is neither true, false, nor nil, or additional_owners is neither nil nor an Array of
138
+ # at least one user identifier
139
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for a request
140
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read and
141
+ # leaves out the value: the request
142
+ # that initializes the upload raises it as it is, and a chunk or the finalize that opens a connection fails
143
+ # with it as the cause of the ChunkedUploadFailed it raises
144
+ # @raise [InvalidMediaType] if no media type is given and none can be inferred, or the one the media is, read
145
+ # from its bytes or else from the name of its file, is not one the category takes
146
+ # @raise [MissingMediaData] if the response that initializes the upload holds no media to append the chunks to
147
+ # @raise [ChunkedUploadFailed] if the upload is initialized, but a chunk cannot be appended, or it cannot be
148
+ # finalized, with the media it initialized
149
+ # @raise [TokenReportFailed] if save_tokens raises for the tokens of a refresh a request of the upload made, with
150
+ # the tokens, rather than the media, whatever the upload had done by then
151
+ # @example Upload a long video, and wait for X to process it once it is needed
152
+ # video = client.chunked_upload_media("talk.mp4", concurrency: 8)
153
+ # client.create_post("Watch the talk", media_ids: [client.await_media_processing!(video)])
154
+ def chunked_upload_media(media, **options) # steep:ignore DifferentMethodParameterKind
155
+ MediaUpload.chunked_upload(media, client: _ = self, **Utils.without_client(options))
156
+ end
157
+
158
+ # Wait until media has been processed, whether its processing succeeded or failed
159
+ #
160
+ # It returns the status X reported, which failed? tells a failure by, and ready? a success by;
161
+ # await_media_processing! raises for a failure instead. It waits through any other state, one X does not
162
+ # document among them, as it waits while processing is pending or in progress. Media that already says its
163
+ # processing succeeded or failed, or holds an upload response that names no processing, such as that of an
164
+ # image, is returned as it is, without a request.
165
+ #
166
+ # @api public
167
+ # @param media [UploadedMedia, Hash, #media_key, String, Integer] the uploaded media, media that has a media key,
168
+ # such as X::Media, the media key, or the media identifier
169
+ # @param options [Hash] the options of {MediaUpload.await_processing}
170
+ # @option options [Integer, Float, nil] :processing_timeout (600) the seconds from now to wait for processing to
171
+ # finish, checks and all, of at least 0, or nil to wait for as long as processing takes
172
+ # @return [UploadedMedia] the uploaded media, which holds the processing status, failed or not, or the media
173
+ # given, as uploaded media, if its processing has already ended
174
+ # @raise [ArgumentError] if the processing timeout is neither nil nor a finite number of seconds of at least 0
175
+ # @raise [ArgumentError] if the media given is nil, holds no identifier, or is neither media, a media key, nor a
176
+ # media identifier, or its media key names none
177
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
178
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read and
179
+ # leaves out the value
180
+ # @raise [MissingMediaData] if a status response holds no media or carries no body at all
181
+ # @raise [MediaProcessingTimeout] if the media is still processing once the processing timeout would pass
182
+ # @example Wait for a video uploaded with chunked_upload_media
183
+ # video = client.await_media_processing(video)
184
+ # warn "#{video.id} failed to process" if video.failed?
185
+ def await_media_processing(media, **options) # steep:ignore DifferentMethodParameterKind
186
+ MediaUpload.await_processing(media, client: _ = self, **Utils.without_client(options))
187
+ end
188
+
189
+ # Wait until media has been processed, raising if its processing failed
190
+ #
191
+ # @api public
192
+ # @param media [UploadedMedia, Hash, #media_key, String, Integer] the uploaded media, media that has a media key,
193
+ # such as X::Media, the media key, or the media identifier
194
+ # @param options [Hash] the options of {MediaUpload.await_processing!}
195
+ # @option options [Integer, Float, nil] :processing_timeout (600) the seconds from now to wait for processing to
196
+ # finish, checks and all, of at least 0, or nil to wait for as long as processing takes
197
+ # @return [UploadedMedia] the uploaded media, which holds the processing status, or the media given, as uploaded
198
+ # media, if its processing has already succeeded
199
+ # @raise [ArgumentError] if the processing timeout is neither nil nor a finite number of seconds of at least 0
200
+ # @raise [ArgumentError] if the media given is nil, holds no identifier, or is neither media, a media key, nor a
201
+ # media identifier, or its media key names none
202
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
203
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read and
204
+ # leaves out the value
205
+ # @raise [MissingMediaData] if a status response holds no media or carries no body at all
206
+ # @raise [MediaProcessingFailed] if media processing failed, with the status X reported, or the media given,
207
+ # without a request, if it already says its processing failed
208
+ # @raise [MediaProcessingTimeout] if the media is still processing once the processing timeout would pass
209
+ # @example Wait for a video uploaded with chunked_upload_media, raising if X could not process it
210
+ # client.await_media_processing!(video)
211
+ def await_media_processing!(media, **options) # steep:ignore DifferentMethodParameterKind
212
+ MediaUpload.await_processing!(media, client: _ = self, **Utils.without_client(options))
213
+ end
214
+
215
+ # Describe uploaded media with alt text, for people who cannot see it
216
+ #
217
+ # @api public
218
+ # @param media [UploadedMedia, Hash, #media_key, String, Integer] the uploaded media, media that has a media key,
219
+ # such as X::Media, the media key, or the media identifier
220
+ # @param text [String] the alt text, of 1 to 1,000 characters
221
+ # @return [UploadedMedia] the media given, as uploaded media, which a call can be chained to
222
+ # @raise [ArgumentError] if the alt text is empty or longer than the API takes, before a request
223
+ # @raise [ArgumentError] if the media given is nil, holds no identifier, or is neither media, a media key, nor a
224
+ # media identifier, or its media key names none
225
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
226
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read and
227
+ # leaves out the value
228
+ # @raise [MissingMediaData] if the response holds no metadata or carries no body at all
229
+ # @example Describe an image
230
+ # client.add_alt_text(media, "A cat asleep on a keyboard")
231
+ # @example Describe an image as it is uploaded
232
+ # media = client.add_alt_text(client.upload_media("cat.jpg"), "A cat asleep on a keyboard")
233
+ def add_alt_text(media, text)
234
+ Metadata.add_alt_text(media, text, client: _ = self)
235
+ end
236
+
237
+ # Attach uploaded subtitles to an uploaded video
238
+ #
239
+ # @api public
240
+ # @param video [UploadedMedia, Hash, #media_key, String, Integer] the uploaded video, media that has a media
241
+ # key, such as X::Media, or its media identifier
242
+ # @param subtitles [UploadedMedia, Hash, #media_key, String, Integer] the uploaded subtitles, media that has a
243
+ # media key, or their media identifier
244
+ # @param language_code [String] the language of the subtitles, such as EN
245
+ # @param options [Hash] the options of {Metadata.add_subtitles}
246
+ # @option options [String, nil] :display_name (nil) the name of the language shown to viewers, such as English,
247
+ # or nil for none
248
+ # @option options [String, Symbol] :media_category ("tweet_video") the category the video was uploaded as,
249
+ # tweet_video or amplify_video, in any case, or TweetVideo or AmplifyVideo, as the subtitles endpoint names them
250
+ # @return [UploadedMedia] the video given, as uploaded media, which a call can be chained to
251
+ # @raise [ArgumentError] if the media category is neither tweet_video nor amplify_video, or the language code is
252
+ # not two letters
253
+ # @raise [ArgumentError] if the video or the subtitles are nil, hold no identifier, are neither media, a media
254
+ # key, nor a media identifier, have a media key that names none, or an identifier the API does not take
255
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
256
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read and
257
+ # leaves out the value
258
+ # @raise [MissingMediaData] if the response holds no metadata or carries no body at all
259
+ # @example Subtitle a video in English
260
+ # client.add_subtitles(video, subtitles, "EN", display_name: "English")
261
+ def add_subtitles(video, subtitles, language_code, **options) # steep:ignore DifferentMethodParameterKind
262
+ Metadata.add_subtitles(video, subtitles, language_code, client: _ = self, **Utils.without_client(options))
263
+ end
264
+
265
+ # Update the profile image of the authenticated user from a file
266
+ #
267
+ # @api public
268
+ # @param media [String, Pathname, IO, StringIO] the path to the image, or an IO that reads it
269
+ # @return [void]
270
+ # @raise [InvalidMedia] if the file does not exist
271
+ # @raise [ArgumentError] if the media is neither a path nor an IO
272
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
273
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read and
274
+ # leaves out the value
275
+ # @raise [InvalidMedia] if the media cannot be read, is empty, or is larger than the 700 kilobytes the API takes
276
+ # @raise [InvalidMediaType] if the image does not begin with the signature of a GIF, a JPEG, or a PNG
277
+ # @example Update the profile image
278
+ # client.update_profile_image("avatar.png")
279
+ def update_profile_image(media)
280
+ Account.update_profile_image(media, client: _ = self)
281
+ end
282
+
283
+ # Update the profile banner of the authenticated user from a file
284
+ #
285
+ # @api public
286
+ # @param media [String, Pathname, IO, StringIO] the path to the image, or an IO that reads it
287
+ # @param options [Hash] the options of {Account.update_profile_banner}, which give the region of the image to use
288
+ # @option options [Integer, nil] :width (nil) the width of the region, in pixels, of at least 1
289
+ # @option options [Integer, nil] :height (nil) the height of the region, in pixels, of at least 1
290
+ # @option options [Integer, nil] :offset_left (nil) the pixels by which the region is offset from the left, of at
291
+ # least 0
292
+ # @option options [Integer, nil] :offset_top (nil) the pixels by which the region is offset from the top, of at
293
+ # least 0
294
+ # @return [void]
295
+ # @raise [InvalidMedia] if the file does not exist
296
+ # @raise [ArgumentError] if the media is neither a path nor an IO, or a width, height, or offset is neither nil
297
+ # nor an Integer of the pixels it takes
298
+ # @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
299
+ # cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read and
300
+ # leaves out the value
301
+ # @raise [InvalidMedia] if the media cannot be read, is empty, or is larger than the 5 megabytes X takes
302
+ # @raise [InvalidMediaType] if the image does not begin with the signature of a GIF, a JPEG, or a PNG
303
+ # @example Update the profile banner
304
+ # client.update_profile_banner("banner.png", width: 1500, height: 500)
305
+ def update_profile_banner(media, **options) # steep:ignore DifferentMethodParameterKind
306
+ Account.update_profile_banner(media, client: _ = self, **Utils.without_client(options))
307
+ end
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require_relative "error"
5
+ require_relative "uploaded_media"
6
+
7
+ module X
8
+ # Error raised when a chunked upload is initialized, but its chunks cannot be appended or it cannot be finalized
9
+ #
10
+ # The media is created, and its identifier given, once the upload is initialized, so the media is not lost to a
11
+ # chunk or a finalize that fails, as one the API answers with a server error, to media that can no longer be read,
12
+ # as a file deleted, closed, or shrunk once the upload was initialized cannot, or to any other error that ends the
13
+ # upload, as one the on_response hook of the client raises, or a thread for the chunks that cannot be started: the
14
+ # error holds the media the upload initialized, which names it by its identifier and its media key. The error that
15
+ # failed the upload is the cause, whose message the message ends with. A Timeout::Error, as Timeout.timeout raises
16
+ # around the upload, is raised as it is, so that a rescue of it still catches it, unless one raised with its class,
17
+ # as by Timeout.timeout(5, Timeout::Error), lands in the save_tokens of a refresh, which raises TokenReportFailed,
18
+ # holding the tokens. A TokenReportFailed, which a chunk or the finalize raises when save_tokens raised for the
19
+ # tokens of a refresh it made, is raised as it is too, rather than as this error, so that the rescue of it that
20
+ # stores the tokens it holds catches it around an upload as around any other request.
21
+ #
22
+ # @api public
23
+ class ChunkedUploadFailed < Uploads::Error
24
+ # Finish a chunked upload, raising this error, which holds the media, on failure
25
+ #
26
+ # Internal to x-uploads: a chunked upload appends its chunks and finalizes the media through it, and calls it
27
+ # with __send__, since it is private. A Timeout::Error, a TokenReportFailed, which holds the tokens of a refresh
28
+ # that save_tokens raised for, and an exception that is not a StandardError, such as an Interrupt, are raised as
29
+ # they are. The error holds the identifier and media key of the media alone, since the
30
+ # rest of the response that initialized the upload, such as its expires_after_secs, says nothing of whether the
31
+ # media can be attached to a post, so that its ready? is false and await_media_processing checks it.
32
+ #
33
+ # @api private
34
+ # @param media [Hash{String => Object}] the media the upload initialized
35
+ # @yield appends the chunks and finalizes the upload
36
+ # @return [Object] what the block returns
37
+ # @raise [ChunkedUploadFailed] if the block raises a StandardError other than a Timeout::Error or a
38
+ # TokenReportFailed, such as an error of the X API, or one of reading the media, as a file deleted, closed, or
39
+ # shrunk once the upload was initialized raises
40
+ # @raise [TokenReportFailed] if save_tokens raises for the tokens of a refresh the block made, with the tokens
41
+ # @example Finish a chunked upload, keeping the media if it fails
42
+ # X::ChunkedUploadFailed.__send__(:keeping, media) { Uploads::Chunks.finalize(client:, media:) }
43
+ def self.keeping(media)
44
+ yield
45
+ rescue Timeout::Error, TokenReportFailed
46
+ raise
47
+ rescue
48
+ raise new(media: media.slice("id", "media_key"))
49
+ end
50
+ private_class_method :keeping
51
+
52
+ # The media the upload initialized, which names it by its identifier
53
+ # @api public
54
+ # @return [UploadedMedia, nil] the media, or nil if none was given
55
+ # @example Read the identifier of the media an upload left unfinished
56
+ # rescue X::ChunkedUploadFailed => e
57
+ # e.media.id # => 1880028106020515840
58
+ attr_reader :media
59
+
60
+ # Initialize the error with the media the upload initialized
61
+ #
62
+ # The message is the one given, or else names the media by its identifier, when media that holds one was given.
63
+ #
64
+ # @api public
65
+ # @param message [String, nil] the message, or nil for one that names the media
66
+ # @param media [UploadedMedia, Hash{String => Object}, nil] the media the upload initialized, a Hash of which is
67
+ # held as uploaded media
68
+ # @return [ChunkedUploadFailed] a new error
69
+ # @example Raise the error for media whose upload could not be finished
70
+ # raise X::ChunkedUploadFailed.new(media: media)
71
+ # @example Raise the error with a message of its own, as a test stub may
72
+ # raise X::ChunkedUploadFailed, "The upload could not be finished"
73
+ def initialize(message = nil, media: nil)
74
+ @media = media.is_a?(Hash) ? UploadedMedia.new(media) : media
75
+ super(message || ["Media", media&.[]("id"), "was initialized, but its upload could not be finished"].compact.join(" "))
76
+ end
77
+
78
+ # The message, ending with why the upload could not be finished
79
+ #
80
+ # It ends with the message of the error that failed the upload, which is the cause, if there is one, whether the
81
+ # message was given or named the media.
82
+ #
83
+ # @api public
84
+ # @return [String] the message
85
+ # @example Read why the upload could not be finished
86
+ # error.message # => "Media 7 was initialized, but its upload could not be finished: Service Unavailable"
87
+ def to_s = [super, cause&.message].compact.join(": ")
88
+ end
89
+ end