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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 731556dfa3e6417d5e0ea9305d6a6b87cc87bd360d71277df0b3dd1c5c8400b5
4
- data.tar.gz: a1170fee36003355b65fe7a6a2732eb750ab0681bae74539d3f1eb17bfd96f65
3
+ metadata.gz: 4df136d018c47058c7524faada6292a1464b34f2f0e98f2a76e8491630b28d1c
4
+ data.tar.gz: f7101280a194f40bef57a82e9670cd6ddf409e80b7563072384b8f8c7687cb63
5
5
  SHA512:
6
- metadata.gz: ec96213eaf3a727ba153c9df7f4f92649b45d3089944d3c02bf6c56960a593845818b7e9e4c947b0317dc6ec4325c7563652d9bcebd911c677171ba1d397fd21
7
- data.tar.gz: dc3d6fb361ec7de40ac47d065c3840dd4972f7a32ebb17ed3969b56fc6257272d82c66d035b298956ac1209548c54e46530c75dbea3d23b8ebcd341480670a41
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
- First, obtain X credentails from <https://developer.x.com>.
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. Checkout and repo:
135
+ 1. Clone the repo:
135
136
 
136
- git checkout git@github.com:sferik/x-ruby.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(File::NULL, "w")
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
@@ -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
- MEDIA_CATEGORIES = %w[dm_gif dm_image dm_video subtitles tweet_gif tweet_image tweet_video].freeze
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:, media_type: infer_media_type(file_path, media_category),
20
- boundary: SecureRandom.hex)
21
- validate!(file_path:, media_category:)
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 chunked_upload(client:, file_path:, media_category:, media_type: infer_media_type(file_path,
28
- media_category), boundary: SecureRandom.hex, chunk_size_mb: 1)
29
- validate!(file_path:, media_category:)
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:, media_type:, boundary:)
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
- private
46
-
47
- def validate!(file_path:, media_category:)
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
- return if MEDIA_CATEGORIES.include?(media_category.downcase)
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[File.extname(file_path).delete(".").downcase] || DEFAULT_MIME_TYPE
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
- file_number = -1
66
- file_paths = [] # @type var file_paths: Array[String]
67
-
68
- File.open(file_path, "rb") do |f|
69
- while (chunk = f.read(chunk_size))
70
- path = "#{Dir.mktmpdir}/x#{format("%03d", file_number += 1)}"
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:, media_type:, boundary: SecureRandom.hex)
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:, media_type:, segment_index: index, boundary:)
88
- headers = {"Content-Type" => "multipart/form-data, boundary=#{boundary}"}
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(file_path:, media_type:, segment_index: nil, boundary: SecureRandom.hex)
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\"; filename=\"#{File.basename(file_path)}\"\r\n" \
115
- "Content-Type: #{media_type}\r\n\r\n" \
116
- "#{File.binread(file_path)}\r\n" \
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
@@ -1,5 +1,5 @@
1
1
  require "base64"
2
- require "cgi"
2
+ require "cgi/escape"
3
3
  require "json"
4
4
  require "openssl"
5
5
  require "securerandom"
@@ -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.add_field(key, value)
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.delete(key)
48
- request.add_field(key, value)
47
+ request[key] = value
49
48
  end
50
49
  end
51
50
 
data/lib/x/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  require "rubygems/version"
2
2
 
3
3
  module X
4
- VERSION = Gem::Version.create("0.15.4")
4
+ VERSION = Gem::Version.create("0.17.0")
5
5
  end
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, ?media_type: String, ?boundary: String) -> untyped
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, media_type: String, ?boundary: String) -> Array[String]
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: (file_path: String, media_type: String, ?segment_index: Integer, ?boundary: String) -> String
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.15.4
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.8
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: []