x 0.15.4 → 0.17.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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +4 -3
- data/lib/x/connection.rb +1 -1
- data/lib/x/media_upload_validator.rb +17 -0
- data/lib/x/media_uploader.rb +40 -38
- data/lib/x/oauth_authenticator.rb +1 -1
- data/lib/x/request_builder.rb +2 -3
- data/lib/x/version.rb +1 -1
- data/sig/x.rbs +13 -5
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4df136d018c47058c7524faada6292a1464b34f2f0e98f2a76e8491630b28d1c
|
|
4
|
+
data.tar.gz: f7101280a194f40bef57a82e9670cd6ddf409e80b7563072384b8f8c7687cb63
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 24599b2cb2604a711a1a810cb21b0631ce26519dcee977e12bf494fdde1be55c08bf4e2716b6c281918010847e8c1bd72cd236a44f0343ece70a66f4a908ab1f
|
|
7
|
+
data.tar.gz: 84cdf631377ed38f0400a0f82d6dc42609839eeb4ba81d77526017af8541365152adb4f3e73f2722ae2645a494cf697976c1ae85039c9bd2b82edb1efe97136f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## [0.17.0] - 2025-12-02
|
|
2
|
+
* Add MediaUploader.upload_binary method (9f2f108)
|
|
3
|
+
* Don't forward filename during media upload (492214d)
|
|
4
|
+
|
|
5
|
+
## [0.16.0] - 2025-06-24
|
|
6
|
+
* Remove media_type parameter from non-chunked upload and append methods (f1f38b5)
|
|
7
|
+
* Fix media upload (dcb418a)
|
|
8
|
+
* Add await_processing! method to handle media upload failures (6cfc973)
|
|
9
|
+
* Move media_category in body for media upload (b790636)
|
|
10
|
+
|
|
1
11
|
## [0.15.4] - 2025-05-02
|
|
2
12
|
* Use dedicated endpoints for chunked media upload (d54d0d0)
|
|
3
13
|
|
data/README.md
CHANGED
|
@@ -23,7 +23,8 @@ Or, if Bundler is not being used to manage dependencies:
|
|
|
23
23
|
|
|
24
24
|
## Usage
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
> [!NOTE]
|
|
27
|
+
> First, obtain X credentials from <https://developer.x.com>.
|
|
27
28
|
|
|
28
29
|
```ruby
|
|
29
30
|
require "x"
|
|
@@ -131,9 +132,9 @@ Many thanks to our sponsors (listed in order of when they sponsored this project
|
|
|
131
132
|
|
|
132
133
|
## Development
|
|
133
134
|
|
|
134
|
-
1.
|
|
135
|
+
1. Clone the repo:
|
|
135
136
|
|
|
136
|
-
git
|
|
137
|
+
git clone git@github.com:sferik/x-ruby.git
|
|
137
138
|
|
|
138
139
|
2. Enter the repo’s directory:
|
|
139
140
|
|
data/lib/x/connection.rb
CHANGED
|
@@ -13,7 +13,7 @@ module X
|
|
|
13
13
|
DEFAULT_OPEN_TIMEOUT = 60 # seconds
|
|
14
14
|
DEFAULT_READ_TIMEOUT = 60 # seconds
|
|
15
15
|
DEFAULT_WRITE_TIMEOUT = 60 # seconds
|
|
16
|
-
DEFAULT_DEBUG_OUTPUT = File.open(
|
|
16
|
+
DEFAULT_DEBUG_OUTPUT = File.open(IO::NULL, "w")
|
|
17
17
|
NETWORK_ERRORS = [
|
|
18
18
|
Errno::ECONNREFUSED,
|
|
19
19
|
Errno::ECONNRESET,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module X
|
|
2
|
+
module MediaUploadValidator
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
MEDIA_CATEGORIES = %w[dm_gif dm_image dm_video subtitles tweet_gif tweet_image tweet_video].freeze
|
|
6
|
+
|
|
7
|
+
def validate_file_path!(file_path:)
|
|
8
|
+
raise "File not found: #{file_path}" unless File.exist?(file_path)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def validate_media_category!(media_category:)
|
|
12
|
+
return if MEDIA_CATEGORIES.include?(media_category.downcase)
|
|
13
|
+
|
|
14
|
+
raise ArgumentError, "Invalid media_category: #{media_category}. Valid values: #{MEDIA_CATEGORIES.join(", ")}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/x/media_uploader.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
require "json"
|
|
1
2
|
require "securerandom"
|
|
2
3
|
require "tmpdir"
|
|
4
|
+
require_relative "media_upload_validator"
|
|
3
5
|
|
|
4
6
|
module X
|
|
5
7
|
module MediaUploader
|
|
@@ -7,8 +9,7 @@ module X
|
|
|
7
9
|
|
|
8
10
|
MAX_RETRIES = 3
|
|
9
11
|
BYTES_PER_MB = 1_048_576
|
|
10
|
-
|
|
11
|
-
DM_GIF, DM_IMAGE, DM_VIDEO, SUBTITLES, TWEET_GIF, TWEET_IMAGE, TWEET_VIDEO = MEDIA_CATEGORIES
|
|
12
|
+
DM_GIF, DM_IMAGE, DM_VIDEO, SUBTITLES, TWEET_GIF, TWEET_IMAGE, TWEET_VIDEO = MediaUploadValidator::MEDIA_CATEGORIES
|
|
12
13
|
DEFAULT_MIME_TYPE = "application/octet-stream".freeze
|
|
13
14
|
MIME_TYPES = %w[image/gif image/jpeg video/mp4 image/png application/x-subrip image/webp].freeze
|
|
14
15
|
GIF_MIME_TYPE, JPEG_MIME_TYPE, MP4_MIME_TYPE, PNG_MIME_TYPE, SUBRIP_MIME_TYPE, WEBP_MIME_TYPE = MIME_TYPES
|
|
@@ -16,63 +17,63 @@ module X
|
|
|
16
17
|
"png" => PNG_MIME_TYPE, "srt" => SUBRIP_MIME_TYPE, "webp" => WEBP_MIME_TYPE}.freeze
|
|
17
18
|
PROCESSING_INFO_STATES = %w[failed succeeded].freeze
|
|
18
19
|
|
|
19
|
-
def upload(client:, file_path:, media_category:,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
upload_body = construct_upload_body(file_path:, media_type:, boundary:)
|
|
23
|
-
headers = {"Content-Type" => "multipart/form-data, boundary=#{boundary}"}
|
|
24
|
-
client.post("media/upload?media_category=#{media_category}", upload_body, headers:)
|
|
20
|
+
def upload(client:, file_path:, media_category:, boundary: SecureRandom.hex)
|
|
21
|
+
MediaUploadValidator.validate_file_path!(file_path:)
|
|
22
|
+
upload_binary(client:, content: File.binread(file_path), media_category:, boundary:)
|
|
25
23
|
end
|
|
26
24
|
|
|
27
|
-
def
|
|
28
|
-
media_category
|
|
29
|
-
|
|
25
|
+
def upload_binary(client:, content:, media_category:, boundary: SecureRandom.hex)
|
|
26
|
+
MediaUploadValidator.validate_media_category!(media_category:)
|
|
27
|
+
upload_body = construct_upload_body(content:, media_category:, boundary:)
|
|
28
|
+
headers = {"Content-Type" => "multipart/form-data; boundary=#{boundary}"}
|
|
29
|
+
client.post("media/upload", upload_body, headers:)&.fetch("data")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def chunked_upload(client:, file_path:, media_category:, media_type: infer_media_type(file_path, media_category),
|
|
33
|
+
boundary: SecureRandom.hex, chunk_size_mb: 1)
|
|
34
|
+
MediaUploadValidator.validate_file_path!(file_path:)
|
|
35
|
+
MediaUploadValidator.validate_media_category!(media_category:)
|
|
30
36
|
media = init(client:, file_path:, media_type:, media_category:)
|
|
31
37
|
chunk_size = chunk_size_mb * BYTES_PER_MB
|
|
32
|
-
append(client:, file_paths: split(file_path, chunk_size), media:,
|
|
38
|
+
append(client:, file_paths: split(file_path, chunk_size), media:, boundary:)
|
|
33
39
|
client.post("media/upload/#{media["id"]}/finalize")&.fetch("data")
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
def await_processing(client:, media:)
|
|
37
43
|
loop do
|
|
38
44
|
status = client.get("media/upload?command=STATUS&media_id=#{media["id"]}")&.fetch("data")
|
|
39
|
-
return status if !status["processing_info"] || PROCESSING_INFO_STATES.include?(status["processing_info"]["state"])
|
|
45
|
+
return status if status.nil? || !status["processing_info"] || PROCESSING_INFO_STATES.include?(status["processing_info"]["state"])
|
|
40
46
|
|
|
41
47
|
sleep status["processing_info"]["check_after_secs"].to_i
|
|
42
48
|
end
|
|
43
49
|
end
|
|
44
50
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
raise "File not found: #{file_path}" unless File.exist?(file_path)
|
|
51
|
+
def await_processing!(client:, media:)
|
|
52
|
+
status = await_processing(client:, media:)
|
|
53
|
+
raise "Media processing failed" if status&.dig("processing_info", "state") == "failed"
|
|
49
54
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
raise ArgumentError, "Invalid media_category: #{media_category}. Valid values: #{MEDIA_CATEGORIES.join(", ")}"
|
|
55
|
+
status
|
|
53
56
|
end
|
|
54
57
|
|
|
58
|
+
private
|
|
59
|
+
|
|
55
60
|
def infer_media_type(file_path, media_category)
|
|
56
61
|
case media_category.downcase
|
|
57
62
|
when TWEET_GIF, DM_GIF then GIF_MIME_TYPE
|
|
58
63
|
when TWEET_VIDEO, DM_VIDEO then MP4_MIME_TYPE
|
|
59
64
|
when SUBTITLES then SUBRIP_MIME_TYPE
|
|
60
|
-
else MIME_TYPE_MAP
|
|
65
|
+
else MIME_TYPE_MAP.fetch(File.extname(file_path).delete(".").downcase, DEFAULT_MIME_TYPE)
|
|
61
66
|
end
|
|
62
67
|
end
|
|
63
68
|
|
|
64
69
|
def split(file_path, chunk_size)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
File.binwrite(path, chunk)
|
|
72
|
-
file_paths << path
|
|
73
|
-
end
|
|
70
|
+
file_size = File.size(file_path)
|
|
71
|
+
segment_count = (file_size.to_f / chunk_size).ceil
|
|
72
|
+
(0...segment_count).map do |segment_index|
|
|
73
|
+
segment_path = "#{Dir.mktmpdir}/x#{format("%03d", segment_index + 1)}"
|
|
74
|
+
File.binwrite(segment_path, File.binread(file_path, chunk_size, segment_index * chunk_size))
|
|
75
|
+
segment_path
|
|
74
76
|
end
|
|
75
|
-
file_paths
|
|
76
77
|
end
|
|
77
78
|
|
|
78
79
|
def init(client:, file_path:, media_type:, media_category:)
|
|
@@ -81,11 +82,11 @@ module X
|
|
|
81
82
|
client.post("media/upload/initialize", data)&.fetch("data")
|
|
82
83
|
end
|
|
83
84
|
|
|
84
|
-
def append(client:, file_paths:, media:,
|
|
85
|
+
def append(client:, file_paths:, media:, boundary: SecureRandom.hex)
|
|
85
86
|
threads = file_paths.map.with_index do |file_path, index|
|
|
86
87
|
Thread.new do
|
|
87
|
-
upload_body = construct_upload_body(file_path
|
|
88
|
-
headers = {"Content-Type" => "multipart/form-data
|
|
88
|
+
upload_body = construct_upload_body(content: File.binread(file_path), segment_index: index, boundary:)
|
|
89
|
+
headers = {"Content-Type" => "multipart/form-data; boundary=#{boundary}"}
|
|
89
90
|
upload_chunk(client:, media_id: media["id"], upload_body:, file_path:, headers:)
|
|
90
91
|
end
|
|
91
92
|
end
|
|
@@ -107,13 +108,14 @@ module X
|
|
|
107
108
|
Dir.delete(dirname) if Dir.empty?(dirname)
|
|
108
109
|
end
|
|
109
110
|
|
|
110
|
-
def construct_upload_body(
|
|
111
|
+
def construct_upload_body(content:, media_category: nil, segment_index: nil, boundary: SecureRandom.hex)
|
|
111
112
|
body = ""
|
|
112
113
|
body += "--#{boundary}\r\nContent-Disposition: form-data; name=\"segment_index\"\r\n\r\n#{segment_index}\r\n" if segment_index
|
|
114
|
+
body += "--#{boundary}\r\nContent-Disposition: form-data; name=\"media_category\"\r\n\r\n#{media_category}\r\n" if media_category
|
|
113
115
|
"#{body}--#{boundary}\r\n" \
|
|
114
|
-
"Content-Disposition: form-data; name=\"media\"
|
|
115
|
-
"Content-Type: #{
|
|
116
|
-
"#{
|
|
116
|
+
"Content-Disposition: form-data; name=\"media\"\r\n" \
|
|
117
|
+
"Content-Type: #{DEFAULT_MIME_TYPE}\r\n\r\n" \
|
|
118
|
+
"#{content}\r\n" \
|
|
117
119
|
"--#{boundary}--\r\n"
|
|
118
120
|
end
|
|
119
121
|
end
|
data/lib/x/request_builder.rb
CHANGED
|
@@ -38,14 +38,13 @@ module X
|
|
|
38
38
|
|
|
39
39
|
def add_authentication(request:, authenticator:)
|
|
40
40
|
authenticator.header(request).each do |key, value|
|
|
41
|
-
request
|
|
41
|
+
request[key] = value
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def add_headers(request:, headers:)
|
|
46
46
|
DEFAULT_HEADERS.merge(headers).each do |key, value|
|
|
47
|
-
request
|
|
48
|
-
request.add_field(key, value)
|
|
47
|
+
request[key] = value
|
|
49
48
|
end
|
|
50
49
|
end
|
|
51
50
|
|
data/lib/x/version.rb
CHANGED
data/sig/x.rbs
CHANGED
|
@@ -245,7 +245,6 @@ module X
|
|
|
245
245
|
module MediaUploader
|
|
246
246
|
MAX_RETRIES: Integer
|
|
247
247
|
BYTES_PER_MB: Integer
|
|
248
|
-
MEDIA_CATEGORIES: Array[String]
|
|
249
248
|
DM_GIF: String
|
|
250
249
|
DM_IMAGE: String
|
|
251
250
|
DM_VIDEO: String
|
|
@@ -265,19 +264,28 @@ module X
|
|
|
265
264
|
PROCESSING_INFO_STATES: Array[String]
|
|
266
265
|
extend MediaUploader
|
|
267
266
|
|
|
268
|
-
def upload: (client: Client, file_path: String, media_category: String, ?
|
|
267
|
+
def upload: (client: Client, file_path: String, media_category: String, ?boundary: String) -> untyped
|
|
268
|
+
def upload_binary: (client: Client, content: String, media_category: String, ?boundary: String) -> untyped
|
|
269
269
|
def chunked_upload: (client: Client, file_path: String, media_category: String, ?media_type: String, ?boundary: String, ?chunk_size_mb: Integer) -> untyped
|
|
270
270
|
def await_processing: (client: Client, media: untyped) -> untyped
|
|
271
|
+
def await_processing!: (client: Client, media: untyped) -> untyped
|
|
271
272
|
|
|
272
273
|
private
|
|
273
|
-
def validate!: (file_path: String, media_category: String) -> nil
|
|
274
274
|
def infer_media_type: (String file_path, String media_category) -> String
|
|
275
275
|
def split: (String file_path, Integer chunk_size) -> Array[String]
|
|
276
276
|
def init: (client: Client, file_path: String, media_type: String, media_category: String) -> untyped
|
|
277
|
-
def append: (client: Client, file_paths: Array[String], media: untyped,
|
|
277
|
+
def append: (client: Client, file_paths: Array[String], media: untyped, ?boundary: String) -> Array[String]
|
|
278
278
|
def upload_chunk: (client: Client, media_id: String, upload_body: String, file_path: String, ?headers: Hash[String, String]) -> Integer?
|
|
279
279
|
def cleanup_file: (String file_path) -> Integer?
|
|
280
280
|
def finalize: (client: Client, media: untyped) -> untyped
|
|
281
|
-
def construct_upload_body: (
|
|
281
|
+
def construct_upload_body: (content: String, ?media_category: String, ?segment_index: Integer, ?boundary: String) -> String
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
module MediaUploadValidator
|
|
285
|
+
MEDIA_CATEGORIES: Array[String]
|
|
286
|
+
extend MediaUploadValidator
|
|
287
|
+
|
|
288
|
+
def validate_file_path!: (file_path: String) -> nil
|
|
289
|
+
def validate_media_category!: (media_category: String) -> nil
|
|
282
290
|
end
|
|
283
291
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: x
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.17.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Erik Berlin
|
|
@@ -59,6 +59,7 @@ files:
|
|
|
59
59
|
- lib/x/errors/too_many_requests.rb
|
|
60
60
|
- lib/x/errors/unauthorized.rb
|
|
61
61
|
- lib/x/errors/unprocessable_entity.rb
|
|
62
|
+
- lib/x/media_upload_validator.rb
|
|
62
63
|
- lib/x/media_uploader.rb
|
|
63
64
|
- lib/x/oauth_authenticator.rb
|
|
64
65
|
- lib/x/rate_limit.rb
|
|
@@ -93,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
93
94
|
- !ruby/object:Gem::Version
|
|
94
95
|
version: '0'
|
|
95
96
|
requirements: []
|
|
96
|
-
rubygems_version: 3.6.
|
|
97
|
+
rubygems_version: 3.6.9
|
|
97
98
|
specification_version: 4
|
|
98
99
|
summary: A Ruby interface to the X API.
|
|
99
100
|
test_files: []
|