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,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "x/core"
|
|
4
|
+
require_relative "json_classes"
|
|
5
|
+
require_relative "missing_media_data"
|
|
6
|
+
require_relative "utils"
|
|
7
|
+
require_relative "validator"
|
|
8
|
+
|
|
9
|
+
module X
|
|
10
|
+
module Uploads
|
|
11
|
+
# Describes uploaded media with alt text, and uploaded videos with subtitles
|
|
12
|
+
# @api public
|
|
13
|
+
module Metadata
|
|
14
|
+
extend self
|
|
15
|
+
|
|
16
|
+
# The media category of a video attached to a post, which subtitles default to, named as the uploaders name it
|
|
17
|
+
SUBTITLED_MEDIA_CATEGORY = "tweet_video"
|
|
18
|
+
private_constant :SUBTITLED_MEDIA_CATEGORY
|
|
19
|
+
|
|
20
|
+
# Describe uploaded media with alt text, for people who cannot see it
|
|
21
|
+
#
|
|
22
|
+
# Alt text added twice is added once, so it is sent again after a server or network error, up to the
|
|
23
|
+
# max_retries of the client, even after a timeout, which a client sends no read again after: X bills a metadata
|
|
24
|
+
# request each time it is sent, so one whose answer was lost may be billed twice.
|
|
25
|
+
#
|
|
26
|
+
# It returns the media it described, as uploaded media, so that a call can be chained to the upload it
|
|
27
|
+
# describes. The response holds nothing more than the media identifier and the alt text that was sent.
|
|
28
|
+
#
|
|
29
|
+
# @api public
|
|
30
|
+
# @param media [UploadedMedia, Hash, #media_key, String, Integer] the uploaded media, media that has a media key,
|
|
31
|
+
# such as X::Media, the media key, or the media identifier
|
|
32
|
+
# @param text [String] the alt text, of 1 to 1,000 characters
|
|
33
|
+
# @param client [Client] the X API client
|
|
34
|
+
# @return [UploadedMedia] the media given, if it is uploaded media, or else uploaded media built from the upload
|
|
35
|
+
# response, the media key, or the media identifier given
|
|
36
|
+
# @raise [ArgumentError] if the alt text is empty or longer than the API takes, before a request
|
|
37
|
+
# @raise [ArgumentError] if the media given is nil, holds no identifier, or is neither media, a media key, nor a
|
|
38
|
+
# media identifier, or its media key names none
|
|
39
|
+
# @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
|
|
40
|
+
# cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read and
|
|
41
|
+
# leaves out the value
|
|
42
|
+
# @raise [MissingMediaData] if the response holds no metadata or carries no body at all
|
|
43
|
+
# @example Describe an uploaded image
|
|
44
|
+
# Uploads::Metadata.add_alt_text(media, "A cat asleep on a keyboard", client: client)
|
|
45
|
+
# @example Describe an image as it is uploaded, and attach it to a post
|
|
46
|
+
# media = Uploads::Metadata.add_alt_text(Uploads::MediaUpload.upload("cat.jpg", client:), "A cat", client:)
|
|
47
|
+
# client.post("tweets", {text: "Look at this cat", media: {media_ids: [media.media_id.to_s]}})
|
|
48
|
+
def add_alt_text(media, text, client:)
|
|
49
|
+
Validator.validate_alt_text!(text)
|
|
50
|
+
body = {id: Utils.media_id(media), metadata: {alt_text: {text:}}}
|
|
51
|
+
Utils.described(Utils.sending_again(client) { client.post("media/metadata", body, **JSON_CLASSES) }, media)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Attach uploaded subtitles to an uploaded video
|
|
55
|
+
#
|
|
56
|
+
# Subtitles attached twice are attached once, as the track of their language, so they are sent again after a
|
|
57
|
+
# server or network error, as alt text is, up to the max_retries of the client.
|
|
58
|
+
#
|
|
59
|
+
# It returns the video it subtitled, as uploaded media, so that a call can be chained to the upload of the
|
|
60
|
+
# video. The response holds nothing more than the identifiers, the category, and the track that were sent.
|
|
61
|
+
#
|
|
62
|
+
# @api public
|
|
63
|
+
# @param video [UploadedMedia, Hash, #media_key, String, Integer] the uploaded video, media that has a media
|
|
64
|
+
# key, such as X::Media, or its media identifier
|
|
65
|
+
# @param subtitles [UploadedMedia, Hash, #media_key, String, Integer] the uploaded .srt or .vtt file, media
|
|
66
|
+
# that has a media key, or its media identifier
|
|
67
|
+
# @param language_code [String] the two-letter language code of the subtitles, in any case, such as EN
|
|
68
|
+
# @param client [Client] the X API client
|
|
69
|
+
# @param display_name [String, nil] the name of the language shown to viewers, such as English
|
|
70
|
+
# @param media_category [String, Symbol] the category the video was uploaded as, tweet_video, the default, or
|
|
71
|
+
# amplify_video, in any case, as the uploaders take it, or as the subtitles endpoint names it, TweetVideo or
|
|
72
|
+
# AmplifyVideo
|
|
73
|
+
# @return [UploadedMedia] the video given, if it is uploaded media, or else uploaded media built from the upload
|
|
74
|
+
# response, the media key, or the media identifier given
|
|
75
|
+
# @raise [ArgumentError] if the media category is neither tweet_video nor amplify_video, or the language code is
|
|
76
|
+
# not two letters
|
|
77
|
+
# @raise [ArgumentError] if the video or the subtitles are nil, hold no identifier, are neither media, a media
|
|
78
|
+
# key, nor a media identifier, have a media key that names none, or an identifier the API does not take
|
|
79
|
+
# @raise [ArgumentError] if the client was given no proxy_url and the proxy the environment names for the request
|
|
80
|
+
# cannot be parsed, or is not an http or https URL with a host, with a message that names the variables read and
|
|
81
|
+
# leaves out the value
|
|
82
|
+
# @raise [MissingMediaData] if the response holds no metadata or carries no body at all
|
|
83
|
+
# @example Upload a video and its English subtitles
|
|
84
|
+
# video = Uploads::MediaUpload.upload("cat.mp4", client: client)
|
|
85
|
+
# subtitles = Uploads::MediaUpload.upload("cat.srt", client: client)
|
|
86
|
+
# Uploads::Metadata.add_subtitles(video, subtitles, "EN", client: client, display_name: "English")
|
|
87
|
+
# @example Subtitle an Amplify video
|
|
88
|
+
# video = Uploads::MediaUpload.upload("cat.mp4", client: client, media_category: :amplify_video)
|
|
89
|
+
# Uploads::Metadata.add_subtitles(video, subtitles, "EN", client: client, media_category: :amplify_video)
|
|
90
|
+
def add_subtitles(video, subtitles, language_code, client:, display_name: nil, media_category: SUBTITLED_MEDIA_CATEGORY)
|
|
91
|
+
track = {id: Utils.media_id(subtitles), language_code: Validator.validate_language_code!(language_code), display_name:}.compact
|
|
92
|
+
body = {id: Utils.media_id(video), media_category: Utils.subtitled_media_category(media_category), subtitles: track}
|
|
93
|
+
Utils.described(Utils.sending_again(client) { client.post("media/subtitles", body, **JSON_CLASSES) }, video)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "x/core"
|
|
4
|
+
require_relative "error"
|
|
5
|
+
|
|
6
|
+
module X
|
|
7
|
+
# Raised when a response of an upload holds none of the data it describes the media with
|
|
8
|
+
#
|
|
9
|
+
# The API answers an upload, a status check, and a change of metadata with what it acted on, under the data of
|
|
10
|
+
# the response. A response that succeeded without it holds nothing an upload can go on, so the uploaders raise
|
|
11
|
+
# this rather than fail later on what is missing. So does one whose media is not what the API documents, such as an
|
|
12
|
+
# identifier that is not one, or processing information of another type, as a gateway in front of the API might
|
|
13
|
+
# answer with, rather than fail as it is read. A response that holds problems in place of the data, as one for
|
|
14
|
+
# media X does not know of does, names the reason in them, which the error holds, and names in its message.
|
|
15
|
+
#
|
|
16
|
+
# It descends from X::Uploads::Error, and so from X::Error, so rescuing the failures of an upload catches it.
|
|
17
|
+
#
|
|
18
|
+
# @api public
|
|
19
|
+
class MissingMediaData < Uploads::Error
|
|
20
|
+
# The problems the response reported in place of the data
|
|
21
|
+
# @api public
|
|
22
|
+
# @return [Array<Problem>] the problems, frozen, empty for a response that reported none
|
|
23
|
+
# @example Tell media X does not know of
|
|
24
|
+
# error.problems.any?(&:not_found?)
|
|
25
|
+
attr_reader :problems
|
|
26
|
+
|
|
27
|
+
# Initialize the error with the reason X gave for holding no data
|
|
28
|
+
#
|
|
29
|
+
# The message is the one given, then the detail, or else the message or the title, of the first problem the
|
|
30
|
+
# response reported, as "The response of the status check holds no media: Could not find media".
|
|
31
|
+
#
|
|
32
|
+
# @api public
|
|
33
|
+
# @param message [String, nil] the message, or nil for the reason alone
|
|
34
|
+
# @param problems [Array<Problem>] the problems the response reported in place of the data
|
|
35
|
+
# @return [MissingMediaData] a new error
|
|
36
|
+
# @example Raise the error for a response that holds problems in place of media
|
|
37
|
+
# raise X::MissingMediaData.new("The response holds no media", problems: X::Problem.all_from(response))
|
|
38
|
+
# @example Raise the error with a message alone, as a test stub may
|
|
39
|
+
# raise X::MissingMediaData, "The response holds no media"
|
|
40
|
+
def initialize(message = nil, problems: [])
|
|
41
|
+
@problems = problems.dup.freeze
|
|
42
|
+
reason = problems.first&.then { |problem| problem.detail || problem.message || problem.title }
|
|
43
|
+
parts = [message, reason].compact
|
|
44
|
+
super((parts.join(": ") unless parts.empty?))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require_relative "json_classes"
|
|
5
|
+
|
|
6
|
+
module X
|
|
7
|
+
module Uploads
|
|
8
|
+
# Builds the multipart form requests of the uploads
|
|
9
|
+
#
|
|
10
|
+
# Internal to x-uploads: the uploaders call it rather than mix its methods into themselves, so a class that
|
|
11
|
+
# includes an uploader gains none of them.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
module Multipart
|
|
15
|
+
extend self
|
|
16
|
+
|
|
17
|
+
# The headers of a multipart form request
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
20
|
+
# @param boundary [String] the multipart boundary
|
|
21
|
+
# @return [Hash{String => String}] the content type, which names the boundary
|
|
22
|
+
# @example The headers of a request
|
|
23
|
+
# Uploads::Multipart.headers("boundary") # => {"Content-Type" => "multipart/form-data; boundary=boundary"}
|
|
24
|
+
def headers(boundary) = {"Content-Type" => "multipart/form-data; boundary=#{boundary}"}
|
|
25
|
+
|
|
26
|
+
# Post a multipart form request, with a boundary of its own
|
|
27
|
+
#
|
|
28
|
+
# @api private
|
|
29
|
+
# @param client [Client] the X API client
|
|
30
|
+
# @param url [String] the endpoint, relative to the base URL of the client
|
|
31
|
+
# @param name [String] the name of the field that holds the content
|
|
32
|
+
# @param content [String] the content to upload
|
|
33
|
+
# @param fields [Hash{Symbol => Object}] the form fields that come before the content, less any that are nil
|
|
34
|
+
# @return [Hash, nil] the parsed response, or nil for a response with no body
|
|
35
|
+
# @example Update a profile image
|
|
36
|
+
# Uploads::Multipart.post(client, "../1.1/account/update_profile_image.json", "image", png)
|
|
37
|
+
def post(client, url, name, content, **fields)
|
|
38
|
+
boundary = SecureRandom.hex
|
|
39
|
+
client.post(url, body(name, content, boundary:, **fields), headers: headers(boundary), **JSON_CLASSES)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The body of a multipart form request: any form fields, then the content uploaded
|
|
43
|
+
#
|
|
44
|
+
# @api private
|
|
45
|
+
# @param name [String] the name of the field that holds the content
|
|
46
|
+
# @param content [String] the content to upload
|
|
47
|
+
# @param boundary [String] the multipart boundary
|
|
48
|
+
# @param fields [Hash{Symbol => Object}] the form fields that come before the content, less any that are nil
|
|
49
|
+
# @return [String] the multipart body
|
|
50
|
+
# @example The body of one chunk of a video
|
|
51
|
+
# Uploads::Multipart.body("media", chunk, boundary: "boundary", segment_index: 0)
|
|
52
|
+
def body(name, content, boundary:, **fields)
|
|
53
|
+
fields.compact.map { |field, value| "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{field}\"\r\n\r\n#{value}\r\n" }.join +
|
|
54
|
+
"--#{boundary}\r\n" \
|
|
55
|
+
"Content-Disposition: form-data; name=\"#{name}\"\r\n" \
|
|
56
|
+
"Content-Type: application/octet-stream\r\n\r\n" \
|
|
57
|
+
"#{content}\r\n" \
|
|
58
|
+
"--#{boundary}--\r\n"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
private_constant :Multipart
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module X
|
|
4
|
+
module Uploads
|
|
5
|
+
# Reads the media type of media from the bytes it begins with
|
|
6
|
+
#
|
|
7
|
+
# Media is typed by its signature, the bytes every file of a type begins with, before the name of its file, so
|
|
8
|
+
# that a file named as what it is not is typed as what it is. Only a type the API documents for an upload of a
|
|
9
|
+
# media category it documents is read, so a glTF 3D model, which no category takes, is not among them, and only
|
|
10
|
+
# one whose signature names it on its own, so SubRip subtitles, which begin with nothing a text file could not,
|
|
11
|
+
# are not among them either.
|
|
12
|
+
#
|
|
13
|
+
# Internal to x-uploads: X::Uploads::MediaUpload reads a signature with it.
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
module Signature
|
|
17
|
+
extend self
|
|
18
|
+
|
|
19
|
+
# The brands an MP4 video names after the box type it begins with, which files of other types share with it: a
|
|
20
|
+
# HEIF or AVIF image, and M4A audio, begin with that box type too, and name a brand of their own. The 3GPP and
|
|
21
|
+
# 3GPP2 videos of phones, and the F4V, XAVC, and mobile MP4 videos of cameras and encoders, are MP4 files that
|
|
22
|
+
# name a brand of their own too.
|
|
23
|
+
MP4_BRANDS = ["isom", "iso2", "iso3", "iso4", "iso5", "iso6", "iso7", "iso8", "iso9", "mp41", "mp42", "avc1", "M4V ",
|
|
24
|
+
"M4VH", "M4VP", "dash", "MSNV", "3gp4", "3gp5", "3gp6", "3g2a", "f4v ", "XAVC", "mmp4"].freeze
|
|
25
|
+
|
|
26
|
+
# The media type each signature names, by the bytes that must appear at each offset, most specific first,
|
|
27
|
+
# since the first signature that matches names the type: the brand of a QuickTime file or an MP4 video follows
|
|
28
|
+
# the box type the two share, a WebP file is a RIFF file whose form is named eight bytes in, and an MPEG transport
|
|
29
|
+
# stream, which begins with no header, is one whose first packets each begin with its sync byte, which a
|
|
30
|
+
# transport stream of 188-byte packets begins with and one of the 192-byte packets of an M2TS file holds four
|
|
31
|
+
# bytes in, after a timestamp. A signature of
|
|
32
|
+
# bytes above ASCII is packed from them, since a String literal of those bytes is not the UTF-8 this file is.
|
|
33
|
+
SIGNATURES = {
|
|
34
|
+
{0 => "GIF87a".b} => "image/gif",
|
|
35
|
+
{0 => "GIF89a".b} => "image/gif",
|
|
36
|
+
{0 => [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A].pack("C*")} => "image/png", # \x89PNG\r\n\x1A\n
|
|
37
|
+
{0 => [0xFF, 0xD8, 0xFF].pack("C*")} => "image/jpeg",
|
|
38
|
+
{0 => "BM".b} => "image/bmp",
|
|
39
|
+
{0 => "II*\x00".b} => "image/tiff",
|
|
40
|
+
{0 => "MM\x00*".b} => "image/tiff",
|
|
41
|
+
{0 => "RIFF".b, 8 => "WEBP".b} => "image/webp",
|
|
42
|
+
{4 => "ftypqt ".b} => "video/quicktime",
|
|
43
|
+
**MP4_BRANDS.to_h { |brand| [{4 => "ftyp#{brand}".b}, "video/mp4"] },
|
|
44
|
+
{0 => [0xEF, 0xBB, 0xBF].pack("C*") + "WEBVTT"} => "text/vtt", # a byte order mark before the header
|
|
45
|
+
{0 => "WEBVTT".b} => "text/vtt",
|
|
46
|
+
{0 => "G".b, 188 => "G".b, 376 => "G".b} => "video/mp2t",
|
|
47
|
+
{4 => "G".b, 196 => "G".b, 388 => "G".b} => "video/mp2t"
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
# The bytes of the EBML header a Matroska file begins with, which a WebM file, being Matroska, begins with too
|
|
51
|
+
EBML = [0x1A, 0x45, 0xDF, 0xA3].pack("C*")
|
|
52
|
+
# The DocType element of the EBML header of a WebM file: its identifier, the length of its value, and "webm", which
|
|
53
|
+
# a Matroska file that is not WebM names "matroska" in place of. The header is the first thing in the file, so
|
|
54
|
+
# its DocType is among the bytes a signature is read from.
|
|
55
|
+
WEBM_DOC_TYPE = [0x42, 0x82, 0x84].pack("C*") + "webm".b
|
|
56
|
+
private_constant :MP4_BRANDS, :SIGNATURES, :EBML, :WEBM_DOC_TYPE
|
|
57
|
+
|
|
58
|
+
# The media type the signature of media names
|
|
59
|
+
#
|
|
60
|
+
# A Matroska file is WebM, which the API documents, only when its EBML header names webm as its DocType; the API
|
|
61
|
+
# documents no type for any other Matroska file, such as an .mkv video, so its signature names none.
|
|
62
|
+
#
|
|
63
|
+
# @api private
|
|
64
|
+
# @param bytes [String] the bytes the media begins with, as {Source#sniff} reads them
|
|
65
|
+
# @return [String, nil] the media type, or nil if no signature names one
|
|
66
|
+
# @example Read the media type of a PNG image
|
|
67
|
+
# Uploads::Signature.media_type("\x89PNG\r\n\x1A\n".b) # => "image/png"
|
|
68
|
+
def media_type(bytes)
|
|
69
|
+
return matroska_type(bytes) if matroska?(bytes)
|
|
70
|
+
|
|
71
|
+
SIGNATURES.find { |magic, _| matches?(bytes, magic) }&.last
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Whether media begins with the EBML header of Matroska
|
|
75
|
+
#
|
|
76
|
+
# WebM is Matroska, so a WebM file begins with it too.
|
|
77
|
+
#
|
|
78
|
+
# @api private
|
|
79
|
+
# @param bytes [String] the bytes the media begins with, as {Source#sniff} reads them
|
|
80
|
+
# @return [Boolean] true if the media is Matroska
|
|
81
|
+
# @example Tell a Matroska file
|
|
82
|
+
# Uploads::Signature.matroska?("\x1A\x45\xDF\xA3...".b) # => true
|
|
83
|
+
def matroska?(bytes) = bytes.start_with?(EBML)
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# The media type of a Matroska file, which is WebM when its DocType is webm
|
|
88
|
+
# @api private
|
|
89
|
+
# @param bytes [String] the bytes the media begins with, an EBML header first
|
|
90
|
+
# @return [String, nil] video/webm, or nil for a Matroska file that is not WebM
|
|
91
|
+
def matroska_type(bytes) = ("video/webm" if bytes.include?(WEBM_DOC_TYPE))
|
|
92
|
+
|
|
93
|
+
# Whether media begins with the bytes of a signature
|
|
94
|
+
# @api private
|
|
95
|
+
# @param bytes [String] the bytes the media begins with
|
|
96
|
+
# @param magic [Hash{Integer => String}] the bytes the signature expects at each offset
|
|
97
|
+
# @return [Boolean] true if the media matches the signature
|
|
98
|
+
def matches?(bytes, magic)
|
|
99
|
+
magic.all? { |offset, expected| bytes.byteslice(offset, expected.bytesize).eql?(expected) }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
private_constant :Signature
|
|
103
|
+
end
|
|
104
|
+
end
|