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,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
|