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