runconvert 0.1.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/CHANGELOG.md +7 -0
- data/LICENSE +21 -0
- data/README.md +96 -0
- data/lib/runconvert.rb +390 -0
- metadata +50 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5c004ab09e9f5609e7a9af31221aadecbd99a315cd3bf2cb05245c4fd31174ec
|
|
4
|
+
data.tar.gz: dffa3d774535fe77f139e5982291af020748e21f9cf898999cc50bdbdb64397e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f77fcaccb05f7773cf1ade697a947b0ae5bb94f4df1bc7ea4935f95da574fd936683ac48466d34ae76c2016d5ff8a6fee2d1a7df64c0273160dda379669a423a
|
|
7
|
+
data.tar.gz: e896b309f2d48eda0dde81d98b43d6637ed483d749f8d2cc7e6506c57e2671146def30fc5f59292112f553e536813f1448659d9153e0c03cea3c739a0af17a0c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-11-15)
|
|
4
|
+
|
|
5
|
+
First version, following the v3 API spec: `convert` in one call; jobs (create, get, list, each, wait,
|
|
6
|
+
cancel, delete); file upload and download; operations; `me`; webhook signature checks; typed errors;
|
|
7
|
+
retries that never create a job twice. Ruby 2.7+, standard library only.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 runconvert
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# RunConvert SDK for Ruby
|
|
2
|
+
|
|
3
|
+
Convert files between 240+ formats (3,000+ conversions) with the [RunConvert API](https://www.runconvert.com/docs):
|
|
4
|
+
documents, images, video, audio, ebooks, archives, fonts and CAD. Ruby 2.7+, standard library only.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
gem install runconvert
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
require "runconvert"
|
|
12
|
+
|
|
13
|
+
rc = RunConvert::Client.new # reads RUNCONVERT_API_KEY
|
|
14
|
+
|
|
15
|
+
# One call: upload, convert, wait, download.
|
|
16
|
+
rc.convert("report.docx", to: "pdf", out_dir: "out").saved # => ["out/report.pdf"]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Get an API key in **Dashboard → API Keys** on [runconvert.com](https://www.runconvert.com). Keep keys on the server.
|
|
20
|
+
|
|
21
|
+
## Convert
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# From a URL (imported by RunConvert, nothing to upload)
|
|
25
|
+
rc.convert("https://example.com/talk.mp4", to: "mp3", options: { audioBitrate: "192k" })
|
|
26
|
+
|
|
27
|
+
# From an IO: give a filename (or from:)
|
|
28
|
+
rc.convert(File.open("photo.heic", "rb"), to: "jpg", out_dir: "out")
|
|
29
|
+
|
|
30
|
+
# Progress and a time limit (seconds)
|
|
31
|
+
rc.convert("movie.mov", to: "mp4", out_dir: "out", timeout: 600) { |job| puts job["status"] }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Jobs with your own tasks
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# Merge two PDFs
|
|
38
|
+
job = rc.jobs.create({
|
|
39
|
+
"a" => { operation: "import/url", source_url: "https://example.com/cover.pdf" },
|
|
40
|
+
"b" => { operation: "import/url", source_url: "https://example.com/body.pdf" },
|
|
41
|
+
"merge" => { operation: "convert", input: %w[a b], input_format: "pdf", output_format: "pdf" },
|
|
42
|
+
"export" => { operation: "export/url", input: "merge" }
|
|
43
|
+
}, tag: "merge")
|
|
44
|
+
done = rc.jobs.wait(job["id"])
|
|
45
|
+
RunConvert::Jobs.exported_files(done).each { |f| rc.files.download(f, "out") }
|
|
46
|
+
|
|
47
|
+
# Upload your own file
|
|
48
|
+
job = rc.jobs.create({
|
|
49
|
+
"upload" => { operation: "import/direct/upload" },
|
|
50
|
+
"convert" => { operation: "convert", input: "upload", input_format: "png", output_format: "webp", image_quality: 80 },
|
|
51
|
+
"export" => { operation: "export/url", input: "convert" }
|
|
52
|
+
})
|
|
53
|
+
rc.files.upload(job["tasks"].find { |t| t["name"] == "upload" }, "image.png")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| Method | What it does |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `rc.jobs.create / get / list / each / wait / cancel / delete` | Jobs, with paging (`list`, `each`) and polling (`wait`) |
|
|
59
|
+
| `rc.files.upload(task, path_or_io)` / `download(file, dir)` | Transfers |
|
|
60
|
+
| `rc.operations.list(category:)` / `for_input("mp4")` / `for_pair("mp4", "mp3")` | Formats, targets, engines (no key needed) |
|
|
61
|
+
| `rc.me` | Account, team and credit balance |
|
|
62
|
+
|
|
63
|
+
## Webhooks
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
post "/webhooks/runconvert" do # Sinatra
|
|
67
|
+
body = request.body.read
|
|
68
|
+
event = RunConvert::Webhooks.construct_event(body, request.env["HTTP_X_RUNCONVERT_SIGNATURE"], ENV["RUNCONVERT_WEBHOOK_SECRET"])
|
|
69
|
+
puts "done #{event['job']['id']}" if event["event"] == "job.finished"
|
|
70
|
+
200
|
|
71
|
+
rescue ArgumentError
|
|
72
|
+
400
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Errors
|
|
77
|
+
|
|
78
|
+
| Error | When |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `RunConvert::Error` | The API said no: `status`, `code` (e.g. `BILLING_INSUFFICIENT_CREDITS`), `message` |
|
|
81
|
+
| `RunConvert::JobFailedError` | The job ended in `error`; `task` has `code`, `reason`, `message` |
|
|
82
|
+
| `RunConvert::WaitTimeoutError` | `wait` ran past its `timeout` |
|
|
83
|
+
|
|
84
|
+
Network errors, `429` and `5xx` are retried twice with backoff (`max_retries:`). Creating a job is only
|
|
85
|
+
retried when the request never reached the API, so a job is never created twice.
|
|
86
|
+
|
|
87
|
+
## Development
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
ruby -Ilib test/test_unit.rb # no network
|
|
91
|
+
RUNCONVERT_API_KEY=rc_live_… ruby -Ilib test/test_live.rb # real API, about 2 credits
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
data/lib/runconvert.rb
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
|
|
9
|
+
# Official RunConvert SDK for Ruby: convert files between 240+ formats.
|
|
10
|
+
#
|
|
11
|
+
# rc = RunConvert::Client.new # reads RUNCONVERT_API_KEY
|
|
12
|
+
# rc.convert("report.docx", to: "pdf", out_dir: "out").saved # => ["out/report.pdf"]
|
|
13
|
+
module RunConvert
|
|
14
|
+
VERSION = "0.1.0"
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.runconvert.com/v3"
|
|
16
|
+
|
|
17
|
+
# The API said no: {"success": false, "error": {"code", "message"}}. New codes can appear,
|
|
18
|
+
# so branch on #status for anything you don't recognise.
|
|
19
|
+
class Error < StandardError
|
|
20
|
+
attr_reader :status, :code, :body
|
|
21
|
+
|
|
22
|
+
def initialize(message, status: 0, code: "", body: nil)
|
|
23
|
+
super(message)
|
|
24
|
+
@status = status
|
|
25
|
+
@code = code.to_s
|
|
26
|
+
@body = body
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The job ended with status "error". #task is the first failed task (code, reason, message).
|
|
31
|
+
class JobFailedError < Error
|
|
32
|
+
attr_reader :job, :task
|
|
33
|
+
|
|
34
|
+
def initialize(job, task = nil)
|
|
35
|
+
@job = job
|
|
36
|
+
@task = task
|
|
37
|
+
super((task && task["message"]).to_s.empty? ? "Job #{job['id']} failed" : task["message"], code: task ? task["code"].to_s : "", body: job)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Client::Jobs#wait gave up before the job reached "finished" or "error".
|
|
42
|
+
class WaitTimeoutError < Error
|
|
43
|
+
attr_reader :job
|
|
44
|
+
|
|
45
|
+
def initialize(job, timeout)
|
|
46
|
+
@job = job
|
|
47
|
+
super("Job #{job['id']} still #{job['status']} after #{timeout.round}s", code: "WAIT_TIMEOUT", body: job)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Result of Client#convert.
|
|
52
|
+
ConvertResult = Struct.new(:job, :files, :saved, keyword_init: true)
|
|
53
|
+
|
|
54
|
+
# A raw HTTP exchange (internal).
|
|
55
|
+
Response = Struct.new(:status, :headers, :body)
|
|
56
|
+
|
|
57
|
+
# Webhook signature checks: "sha256=" + hex HMAC-SHA256 of the raw body, keyed with the secret.
|
|
58
|
+
module Webhooks
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
# True when the X-RunConvert-Signature header matches the raw body.
|
|
62
|
+
def verify(raw_body, signature, secret)
|
|
63
|
+
return false if signature.nil? || signature.empty? || secret.nil? || secret.empty?
|
|
64
|
+
|
|
65
|
+
given = signature.strip.downcase.sub(/\Asha256=/, "")
|
|
66
|
+
return false unless given.match?(/\A[0-9a-f]{64}\z/)
|
|
67
|
+
|
|
68
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body.to_s)
|
|
69
|
+
secure_compare(expected, given)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Constant-time comparison of two equal-length strings (OpenSSL's own needs Ruby 3.0+).
|
|
73
|
+
def secure_compare(a, b)
|
|
74
|
+
return OpenSSL.fixed_length_secure_compare(a, b) if OpenSSL.respond_to?(:fixed_length_secure_compare)
|
|
75
|
+
return false unless a.bytesize == b.bytesize
|
|
76
|
+
|
|
77
|
+
diff = 0
|
|
78
|
+
a.bytes.zip(b.bytes) { |x, y| diff |= x ^ y }
|
|
79
|
+
diff.zero?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Verifies the signature, then parses the event. Raises ArgumentError if it doesn't match.
|
|
83
|
+
def construct_event(raw_body, signature, secret)
|
|
84
|
+
raise ArgumentError, "Invalid RunConvert webhook signature" unless verify(raw_body, signature, secret)
|
|
85
|
+
|
|
86
|
+
JSON.parse(raw_body)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The RunConvert API client.
|
|
91
|
+
class Client
|
|
92
|
+
RETRY_STATUS = [408, 425, 429, 500, 502, 503, 504].freeze
|
|
93
|
+
|
|
94
|
+
attr_reader :jobs, :operations, :files, :base_url
|
|
95
|
+
|
|
96
|
+
# api_key: defaults to ENV["RUNCONVERT_API_KEY"]. timeout: per API request, seconds.
|
|
97
|
+
def initialize(api_key: nil, base_url: DEFAULT_BASE_URL, timeout: 60, max_retries: 2)
|
|
98
|
+
@api_key = api_key.nil? ? ENV.fetch("RUNCONVERT_API_KEY", "") : api_key
|
|
99
|
+
@base_url = base_url.sub(%r{/+\z}, "")
|
|
100
|
+
@timeout = timeout
|
|
101
|
+
@max_retries = max_retries
|
|
102
|
+
@user_agent = "runconvert-ruby/#{VERSION} ruby/#{RUBY_VERSION}"
|
|
103
|
+
@jobs = Jobs.new(self)
|
|
104
|
+
@operations = Operations.new(self)
|
|
105
|
+
@files = Files.new(self)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
attr_reader :user_agent
|
|
109
|
+
|
|
110
|
+
def webhooks
|
|
111
|
+
Webhooks
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The account, team and credit balance behind the API key.
|
|
115
|
+
def me
|
|
116
|
+
request(:get, "/me")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Converts one file in one call: upload (or import a URL), convert, wait, download.
|
|
120
|
+
# file is a path, an http(s) URL or an IO. Engine options go in options:.
|
|
121
|
+
def convert(file, to:, from: nil, options: {}, out_dir: nil, filename: nil, tag: nil, timeout: nil, &on_update)
|
|
122
|
+
is_url = file.is_a?(String) && file.match?(%r{\Ahttps?://}i)
|
|
123
|
+
name = filename || (is_url ? URI(file).path : (file.is_a?(String) ? file : (file.respond_to?(:path) ? file.path : "")))
|
|
124
|
+
source = (from || File.extname(name.to_s).delete_prefix(".")).downcase
|
|
125
|
+
raise ArgumentError, 'Can\'t tell the source format: pass from: "mp4" or filename: "video.mp4"' if source.empty?
|
|
126
|
+
|
|
127
|
+
import = is_url ? { operation: "import/url", source_url: file } : { operation: "import/direct/upload" }
|
|
128
|
+
convert_task = options.merge(operation: "convert", input: "import-1", input_format: source, output_format: to.to_s.downcase.delete_prefix("."))
|
|
129
|
+
job = jobs.create({ "import-1" => import, "convert-1" => convert_task, "export-1" => { operation: "export/url", input: "convert-1" } }, tag: tag)
|
|
130
|
+
unless is_url
|
|
131
|
+
task = (job["tasks"] || []).find { |t| t["name"] == "import-1" }
|
|
132
|
+
raise Error.new("The job has no upload URL", body: job) unless task && task["upload_url"]
|
|
133
|
+
|
|
134
|
+
files.upload(task, file, filename: File.basename(name.to_s))
|
|
135
|
+
end
|
|
136
|
+
job = jobs.wait(job["id"], timeout: timeout, &on_update)
|
|
137
|
+
exported = Jobs.exported_files(job)
|
|
138
|
+
saved = out_dir ? exported.map { |f| files.download(f, out_dir) } : []
|
|
139
|
+
ConvertResult.new(job: job, files: exported, saved: saved)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Sends a request and returns "data" (internal; use the resource methods).
|
|
143
|
+
def request(method, path, body: nil, query: nil)
|
|
144
|
+
request_raw(method, path, body: body, query: query).first
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Like #request, returning [data, meta].
|
|
148
|
+
def request_raw(method, path, body: nil, query: nil)
|
|
149
|
+
params = (query || {}).reject { |_, v| v.nil? || v == "" }
|
|
150
|
+
url = @base_url + path + (params.empty? ? "" : "?#{URI.encode_www_form(params)}")
|
|
151
|
+
headers = { "Accept" => "application/json", "User-Agent" => @user_agent }
|
|
152
|
+
headers["Authorization"] = "Bearer #{@api_key}" unless @api_key.empty?
|
|
153
|
+
payload = nil
|
|
154
|
+
unless body.nil?
|
|
155
|
+
headers["Content-Type"] = "application/json"
|
|
156
|
+
payload = JSON.generate(body)
|
|
157
|
+
end
|
|
158
|
+
# POST /jobs is only repeated when it never reached the server: a job is never created twice.
|
|
159
|
+
idempotent = method != :post || path.end_with?("/cancel")
|
|
160
|
+
attempt = 0
|
|
161
|
+
loop do
|
|
162
|
+
begin
|
|
163
|
+
res = send_request(method, url, headers, payload, @timeout)
|
|
164
|
+
rescue Timeout::Error, SystemCallError, IOError, SocketError, OpenSSL::SSL::SSLError => e
|
|
165
|
+
reached = e.is_a?(Timeout::Error)
|
|
166
|
+
if attempt < @max_retries && (idempotent || !reached)
|
|
167
|
+
attempt += 1
|
|
168
|
+
sleep(backoff(attempt))
|
|
169
|
+
next
|
|
170
|
+
end
|
|
171
|
+
raise Error, "Request to RunConvert failed: #{e.message}"
|
|
172
|
+
end
|
|
173
|
+
if RETRY_STATUS.include?(res.status) && attempt < @max_retries && (idempotent || res.status == 429)
|
|
174
|
+
attempt += 1
|
|
175
|
+
sleep(retry_after(res) || backoff(attempt))
|
|
176
|
+
next
|
|
177
|
+
end
|
|
178
|
+
return [nil, {}] if res.status == 204
|
|
179
|
+
|
|
180
|
+
data = begin
|
|
181
|
+
res.body.to_s.empty? ? nil : JSON.parse(res.body)
|
|
182
|
+
rescue JSON::ParserError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
if res.status >= 400 || (data.is_a?(Hash) && data["success"] == false)
|
|
186
|
+
err = data.is_a?(Hash) ? (data["error"] || {}) : {}
|
|
187
|
+
raise Error.new(err["message"] || "RunConvert API error (HTTP #{res.status})", status: res.status, code: err["code"].to_s, body: data || res.body)
|
|
188
|
+
end
|
|
189
|
+
data ||= {}
|
|
190
|
+
return [data["data"], data["meta"] || {}]
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# One HTTP exchange; overridden in tests. body may be a String or an IO (streamed).
|
|
195
|
+
def send_request(method, url, headers, body, timeout)
|
|
196
|
+
uri = URI(url)
|
|
197
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
198
|
+
http.use_ssl = uri.scheme == "https"
|
|
199
|
+
if timeout
|
|
200
|
+
http.open_timeout = 15
|
|
201
|
+
http.read_timeout = timeout
|
|
202
|
+
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
|
|
203
|
+
end
|
|
204
|
+
klass = { get: Net::HTTP::Get, post: Net::HTTP::Post, put: Net::HTTP::Put, delete: Net::HTTP::Delete }.fetch(method)
|
|
205
|
+
req = klass.new(uri.request_uri, headers)
|
|
206
|
+
if body.respond_to?(:read)
|
|
207
|
+
req.body_stream = body
|
|
208
|
+
elsif body
|
|
209
|
+
req.body = body
|
|
210
|
+
end
|
|
211
|
+
res = http.request(req)
|
|
212
|
+
Response.new(res.code.to_i, res.each_header.to_h, res.body.to_s)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Downloads url into dest; returns the status. Overridden in tests.
|
|
216
|
+
def stream_to(url, dest)
|
|
217
|
+
uri = URI(url)
|
|
218
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
219
|
+
http.request(Net::HTTP::Get.new(uri.request_uri, "User-Agent" => @user_agent)) do |res|
|
|
220
|
+
return res.code.to_i if res.code.to_i >= 300
|
|
221
|
+
|
|
222
|
+
File.open(dest, "wb") { |f| res.read_body { |chunk| f.write(chunk) } }
|
|
223
|
+
return res.code.to_i
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
def backoff(attempt)
|
|
231
|
+
[8.0, 0.5 * (2**(attempt - 1))].min * (0.75 + rand / 2)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def retry_after(res)
|
|
235
|
+
value = res.headers.find { |k, _| k.to_s.casecmp("retry-after").zero? }&.last
|
|
236
|
+
seconds = value.to_f
|
|
237
|
+
seconds.positive? ? [seconds, 30.0].min : nil
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Create, read, wait for and stop jobs.
|
|
242
|
+
class Jobs
|
|
243
|
+
def initialize(client)
|
|
244
|
+
@c = client
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Creates a job from named tasks. Returned with its tasks (an upload task carries "upload_url").
|
|
248
|
+
def create(tasks, tag: nil, metadata: nil)
|
|
249
|
+
body = { tasks: tasks }
|
|
250
|
+
body[:tag] = tag if tag
|
|
251
|
+
body[:metadata] = metadata if metadata
|
|
252
|
+
@c.request(:post, "/jobs", body: body, query: { include: "tasks" })
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# A job with its tasks.
|
|
256
|
+
def get(id)
|
|
257
|
+
@c.request(:get, "/jobs/#{escape(id)}", query: { include: "tasks" })
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# One page of jobs, newest first: {"data" => [...], "meta" => {...}}.
|
|
261
|
+
def list(status: nil, tag: nil, page: nil, per_page: nil, include_tasks: false)
|
|
262
|
+
data, meta = @c.request_raw(:get, "/jobs", query: { "filter[status]" => status, "filter[tag]" => tag, page: page, per_page: per_page, include: include_tasks ? "tasks" : nil })
|
|
263
|
+
{ "data" => data || [], "meta" => meta }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Every job, page by page: rc.jobs.each(status: "finished") { |job| ... }
|
|
267
|
+
def each(**filters)
|
|
268
|
+
return enum_for(:each, **filters) unless block_given?
|
|
269
|
+
|
|
270
|
+
page = 1
|
|
271
|
+
loop do
|
|
272
|
+
result = list(page: page, **filters)
|
|
273
|
+
result["data"].each { |j| yield j }
|
|
274
|
+
total = result["meta"]["total_pages"]
|
|
275
|
+
break if result["data"].empty? || (total && page >= total)
|
|
276
|
+
|
|
277
|
+
page += 1
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Cancels pending tasks; credits for unfinished tasks are refunded.
|
|
282
|
+
def cancel(id)
|
|
283
|
+
@c.request(:post, "/jobs/#{escape(id)}/cancel")
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Deletes a job.
|
|
287
|
+
def delete(id)
|
|
288
|
+
@c.request(:delete, "/jobs/#{escape(id)}")
|
|
289
|
+
nil
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Polls until "finished" or "error" (0.7 s, slowing to 3 s). Raises JobFailedError for a failed job.
|
|
293
|
+
def wait(id, timeout: nil, raise_on_error: true)
|
|
294
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
295
|
+
delay = 0.7
|
|
296
|
+
loop do
|
|
297
|
+
job = get(id)
|
|
298
|
+
yield job if block_given?
|
|
299
|
+
return job if job["status"] == "finished"
|
|
300
|
+
if job["status"] == "error"
|
|
301
|
+
return job unless raise_on_error
|
|
302
|
+
|
|
303
|
+
raise JobFailedError.new(job, (job["tasks"] || []).find { |t| t["status"] == "error" })
|
|
304
|
+
end
|
|
305
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
306
|
+
raise WaitTimeoutError.new(job, timeout) if timeout && elapsed + delay > timeout
|
|
307
|
+
|
|
308
|
+
sleep(delay)
|
|
309
|
+
delay = [delay + 0.3, 3.0].min
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The files produced by the job's export/url tasks.
|
|
314
|
+
def self.exported_files(job)
|
|
315
|
+
(job["tasks"] || []).select { |t| t["operation"] == "export/url" }.flat_map { |t| (t["result"] || {})["files"] || [] }
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
private
|
|
319
|
+
|
|
320
|
+
def escape(id)
|
|
321
|
+
URI.encode_www_form_component(id.to_s)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Formats, targets, engines and options. Public: no key needed.
|
|
326
|
+
class Operations
|
|
327
|
+
def initialize(client)
|
|
328
|
+
@c = client
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Every input format with its targets.
|
|
332
|
+
def list(category: nil)
|
|
333
|
+
@c.request(:get, "/operations", query: { category: category })
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# What one format converts to, with credit costs.
|
|
337
|
+
def for_input(input)
|
|
338
|
+
@c.request(:get, "/operations", query: { input: input.to_s.downcase })
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Engines for one conversion and the options a convert task accepts.
|
|
342
|
+
def for_pair(input, output)
|
|
343
|
+
@c.request(:get, "/operations", query: { input: input.to_s.downcase, output: output.to_s.downcase })
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Uploads to import/direct/upload tasks and downloads of results.
|
|
348
|
+
class Files
|
|
349
|
+
def initialize(client)
|
|
350
|
+
@c = client
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# PUTs a path or IO to the task's "upload_url" before its deadline.
|
|
354
|
+
def upload(task, file, filename: nil)
|
|
355
|
+
upload_url = task.is_a?(String) ? task : task["upload_url"]
|
|
356
|
+
raise ArgumentError, "This task has no upload_url (is it an import/direct/upload task?)" if upload_url.to_s.empty?
|
|
357
|
+
|
|
358
|
+
filename ||= File.basename(file) if file.is_a?(String)
|
|
359
|
+
uri = URI(upload_url)
|
|
360
|
+
query = URI.decode_www_form(uri.query || "").to_h
|
|
361
|
+
query["filename"] = filename if filename
|
|
362
|
+
uri.query = URI.encode_www_form(query)
|
|
363
|
+
headers = { "Content-Type" => "application/octet-stream", "User-Agent" => @c.user_agent }
|
|
364
|
+
res = if file.is_a?(String)
|
|
365
|
+
File.open(file, "rb") do |io|
|
|
366
|
+
headers["Content-Length"] = File.size(file).to_s
|
|
367
|
+
@c.send_request(:put, uri.to_s, headers, io, nil)
|
|
368
|
+
end
|
|
369
|
+
else
|
|
370
|
+
headers["Content-Length"] = file.size.to_s if file.respond_to?(:size)
|
|
371
|
+
@c.send_request(:put, uri.to_s, headers, file, nil)
|
|
372
|
+
end
|
|
373
|
+
raise Error.new("Upload failed (HTTP #{res.status}): #{res.body.to_s[0, 300]}", status: res.status) if res.status >= 300
|
|
374
|
+
|
|
375
|
+
nil
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Saves a result file (or a URL) into dir keeping its name; returns the path.
|
|
379
|
+
def download(file, dir, filename: nil)
|
|
380
|
+
url = file.is_a?(String) ? file : file["url"]
|
|
381
|
+
name = filename || (file.is_a?(String) ? File.basename(URI(url).path) : file["filename"])
|
|
382
|
+
FileUtils.mkdir_p(dir)
|
|
383
|
+
dest = File.join(dir, File.basename(name))
|
|
384
|
+
status = @c.stream_to(url, dest)
|
|
385
|
+
raise Error.new("Download failed (HTTP #{status})", status: status) if status >= 300
|
|
386
|
+
|
|
387
|
+
dest
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: runconvert
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- RunConvert
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-11-15 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description:
|
|
14
|
+
email:
|
|
15
|
+
executables: []
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- CHANGELOG.md
|
|
20
|
+
- LICENSE
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/runconvert.rb
|
|
23
|
+
homepage: https://www.runconvert.com/docs
|
|
24
|
+
licenses:
|
|
25
|
+
- MIT
|
|
26
|
+
metadata:
|
|
27
|
+
source_code_uri: https://github.com/runconvert/runconvert-ruby
|
|
28
|
+
documentation_uri: https://www.runconvert.com/docs
|
|
29
|
+
changelog_uri: https://github.com/runconvert/runconvert-ruby/blob/main/CHANGELOG.md
|
|
30
|
+
rubygems_mfa_required: 'true'
|
|
31
|
+
post_install_message:
|
|
32
|
+
rdoc_options: []
|
|
33
|
+
require_paths:
|
|
34
|
+
- lib
|
|
35
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.7'
|
|
40
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
41
|
+
requirements:
|
|
42
|
+
- - ">="
|
|
43
|
+
- !ruby/object:Gem::Version
|
|
44
|
+
version: '0'
|
|
45
|
+
requirements: []
|
|
46
|
+
rubygems_version: 3.5.22
|
|
47
|
+
signing_key:
|
|
48
|
+
specification_version: 4
|
|
49
|
+
summary: 'Official RunConvert SDK for Ruby: convert files between 240+ formats'
|
|
50
|
+
test_files: []
|