rill-node 0.0.0-stage → 2026.11.14

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.
Files changed (82) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +64 -2
  4. package/binding.gyp +31 -0
  5. package/index.d.ts +45 -0
  6. package/index.js +112 -0
  7. package/native/darwin-arm64/librill.dylib +0 -0
  8. package/native/darwin-arm64/rill-media +0 -0
  9. package/native/darwin-arm64/rill_node.node +0 -0
  10. package/native/darwin-x64/librill.dylib +0 -0
  11. package/native/darwin-x64/rill-media +0 -0
  12. package/native/darwin-x64/rill_node.node +0 -0
  13. package/native/linux-arm64-gnu/librill.so +0 -0
  14. package/native/linux-arm64-gnu/rill-media +0 -0
  15. package/native/linux-arm64-gnu/rill_node.node +0 -0
  16. package/native/linux-x64-gnu/librill.so +0 -0
  17. package/native/linux-x64-gnu/rill-media +0 -0
  18. package/native/linux-x64-gnu/rill_node.node +0 -0
  19. package/native-src/include/rill.h +90 -0
  20. package/native-src/include/rill_media.h +22 -0
  21. package/native-src/shard.yml +9 -0
  22. package/native-src/src/c_api.cr +194 -0
  23. package/native-src/src/cli.cr +637 -0
  24. package/native-src/src/rill/c_api/job.cr +336 -0
  25. package/native-src/src/rill/c_api/memory.cr +32 -0
  26. package/native-src/src/rill/c_api/status.cr +25 -0
  27. package/native-src/src/rill/c_api/sync.cr +125 -0
  28. package/native-src/src/rill/c_api/worker_pool.cr +403 -0
  29. package/native-src/src/rill/cancellation.cr +29 -0
  30. package/native-src/src/rill/client.cr +52 -0
  31. package/native-src/src/rill/cookie_jar.cr +130 -0
  32. package/native-src/src/rill/dash/manifest.cr +279 -0
  33. package/native-src/src/rill/dash/parser.cr +227 -0
  34. package/native-src/src/rill/download_options.cr +96 -0
  35. package/native-src/src/rill/download_plan.cr +27 -0
  36. package/native-src/src/rill/download_progress.cr +16 -0
  37. package/native-src/src/rill/downloader.cr +662 -0
  38. package/native-src/src/rill/errors.cr +155 -0
  39. package/native-src/src/rill/extractor.cr +18 -0
  40. package/native-src/src/rill/extractor_registry.cr +98 -0
  41. package/native-src/src/rill/extractors/bandcamp.cr +265 -0
  42. package/native-src/src/rill/extractors/dash.cr +77 -0
  43. package/native-src/src/rill/extractors/direct_media.cr +73 -0
  44. package/native-src/src/rill/extractors/generic.cr +441 -0
  45. package/native-src/src/rill/extractors/hls.cr +202 -0
  46. package/native-src/src/rill/extractors/internet_archive.cr +180 -0
  47. package/native-src/src/rill/extractors/m3u.cr +81 -0
  48. package/native-src/src/rill/extractors/media_ccc_de.cr +219 -0
  49. package/native-src/src/rill/extractors/mixcloud.cr +495 -0
  50. package/native-src/src/rill/extractors/peer_tube.cr +226 -0
  51. package/native-src/src/rill/extractors/rumble.cr +325 -0
  52. package/native-src/src/rill/extractors/soundcloud.cr +553 -0
  53. package/native-src/src/rill/extractors/spotify.cr +184 -0
  54. package/native-src/src/rill/extractors/twitch_clip.cr +273 -0
  55. package/native-src/src/rill/extractors/youtube.cr +520 -0
  56. package/native-src/src/rill/format.cr +82 -0
  57. package/native-src/src/rill/format_selector.cr +143 -0
  58. package/native-src/src/rill/hls/parser.cr +257 -0
  59. package/native-src/src/rill/hls/playlist.cr +100 -0
  60. package/native-src/src/rill/http_client.cr +503 -0
  61. package/native-src/src/rill/http_url.cr +28 -0
  62. package/native-src/src/rill/json_value.cr +66 -0
  63. package/native-src/src/rill/media.cr +151 -0
  64. package/native-src/src/rill/media_muxer.cr +333 -0
  65. package/native-src/src/rill/mime_type.cr +58 -0
  66. package/native-src/src/rill/output_file_name.cr +91 -0
  67. package/native-src/src/rill/playlist.cr +109 -0
  68. package/native-src/src/rill/request_options.cr +179 -0
  69. package/native-src/src/rill/segment_assembly.cr +70 -0
  70. package/native-src/src/rill/timestamp.cr +34 -0
  71. package/native-src/src/rill/version.cr +3 -0
  72. package/native-src/src/rill.cr +47 -0
  73. package/package.json +48 -4
  74. package/scripts/build-native.js +46 -0
  75. package/scripts/install-gyp.js +10 -0
  76. package/scripts/install.js +19 -0
  77. package/scripts/lib.js +1 -0
  78. package/scripts/libdir.js +23 -0
  79. package/scripts/package-native.js +35 -0
  80. package/scripts/platform.js +41 -0
  81. package/scripts/prepack.js +47 -0
  82. package/src/addon.c +498 -0
@@ -0,0 +1,503 @@
1
+ require "http/client"
2
+ require "uri"
3
+
4
+ require "./cookie_jar"
5
+ require "./errors"
6
+ require "./request_options"
7
+
8
+ module Rill
9
+ class HTTPClient
10
+ REDIRECT_STATUSES = {
11
+ 301, 302, 303, 307, 308,
12
+ }
13
+
14
+ RETRY_STATUSES = {
15
+ 408, 425, 429, 500, 502, 503, 504,
16
+ }
17
+
18
+ SENSITIVE_HEADERS = {
19
+ "authorization", "cookie", "proxy-authorization", "host",
20
+ }
21
+
22
+ HEX = "0123456789ABCDEF"
23
+
24
+ class Response
25
+ getter status : HTTP::Status
26
+ getter body : String
27
+ getter headers : HTTP::Headers
28
+ getter url : URI
29
+
30
+ def initialize(
31
+ @status : HTTP::Status,
32
+ @body : String,
33
+ @headers : HTTP::Headers,
34
+ @url : URI,
35
+ )
36
+ end
37
+ end
38
+
39
+ @cookies : CookieJar
40
+ @header_cookies : Hash(String, String)
41
+
42
+ def initialize(@options : RequestOptions = RequestOptions.new)
43
+ @cookies = CookieJar.new
44
+ @header_cookies = parse_cookie_values(@options.headers.get?("Cookie"))
45
+ validate_options!
46
+ end
47
+
48
+ getter cookies : CookieJar
49
+
50
+ def get(url : String | URI, headers : HTTP::Headers? = nil) : Response
51
+ follow_redirects(url, headers) do |current_url, request_headers|
52
+ attempt_buffered(current_url, request_headers)
53
+ end
54
+ end
55
+
56
+ def post(url : String | URI, body : String, headers : HTTP::Headers? = nil) : Response
57
+ request_headers = HTTP::Headers.new
58
+ copy_headers(headers, request_headers) if headers
59
+ request_headers["Content-Type"] ||= "application/json"
60
+
61
+ follow_redirects(url, request_headers) do |current_url, headers_for_request|
62
+ attempt_buffered(current_url, headers_for_request, "POST", body)
63
+ end
64
+ end
65
+
66
+ def stream_to(
67
+ url : String | URI,
68
+ destination : IO,
69
+ headers : HTTP::Headers? = nil,
70
+ max_bytes : Int64? = nil,
71
+ &before_body : HTTP::Status, HTTP::Headers, URI ->
72
+ ) : Response
73
+ follow_redirects(url, headers) do |current_url, request_headers|
74
+ attempt_stream(current_url, request_headers, destination, max_bytes) do |status, response_headers, response_url|
75
+ before_body.call(status, response_headers, response_url)
76
+ end
77
+ end
78
+ end
79
+
80
+ def stream_to(
81
+ url : String | URI,
82
+ destination : IO,
83
+ headers : HTTP::Headers? = nil,
84
+ max_bytes : Int64? = nil,
85
+ ) : Response
86
+ stream_to(url, destination, headers, max_bytes) { |_, _, _| }
87
+ end
88
+
89
+ private def follow_redirects(
90
+ url : String | URI,
91
+ extra_headers : HTTP::Headers?,
92
+ &request : URI, HTTP::Headers -> Response
93
+ ) : Response
94
+ current_url = parse_url(url)
95
+ origin_url = current_url
96
+ headers = initial_headers(extra_headers)
97
+ extra_cookies = parse_cookie_values(extra_headers.try(&.get?("Cookie")))
98
+ redirects = 0
99
+
100
+ loop do
101
+ same_origin = same_origin?(origin_url, current_url)
102
+ apply_cookies(headers, current_url, extra_cookies, same_origin)
103
+ response = request.call(current_url, headers)
104
+ @cookies.add(response.headers, current_url)
105
+
106
+ if location = redirect_location(response)
107
+ if redirects >= @options.max_redirects
108
+ raise TooManyRedirectsError.new(current_url, @options.max_redirects)
109
+ end
110
+
111
+ target_url = current_url.resolve(location)
112
+ validate_http_url!(target_url)
113
+ headers = redirected_headers(headers, current_url, target_url)
114
+ current_url = target_url
115
+ redirects += 1
116
+ next
117
+ end
118
+
119
+ unless response.status.success?
120
+ raise HTTPError.new(current_url, response.status, response.body)
121
+ end
122
+
123
+ return response
124
+ end
125
+ end
126
+
127
+ private def attempt_buffered(
128
+ url : URI,
129
+ headers : HTTP::Headers,
130
+ method : String = "GET",
131
+ body : String? = nil,
132
+ ) : Response
133
+ attempt = 0
134
+
135
+ loop do
136
+ attempt += 1
137
+
138
+ begin
139
+ response = perform_buffered(url, headers, method, body)
140
+ rescue ex : NetworkError
141
+ raise ex if attempt > @options.retries
142
+ sleep(retry_wait(attempt))
143
+ next
144
+ end
145
+
146
+ return response unless retryable?(response.status) && attempt <= @options.retries
147
+
148
+ sleep(retry_wait(attempt))
149
+ end
150
+ end
151
+
152
+ private def perform_buffered(
153
+ url : URI,
154
+ headers : HTTP::Headers,
155
+ method : String,
156
+ request_body : String?,
157
+ ) : Response
158
+ body = IO::Memory.new
159
+ status = HTTP::Status::OK
160
+ response_headers = HTTP::Headers.new
161
+
162
+ with_client(url) do |client|
163
+ client.exec(build_request(url, headers, method, request_body)) do |response|
164
+ status = response.status
165
+ response_headers = response.headers
166
+
167
+ if status.success?
168
+ read_body(response, url, body, @options.max_response_bytes)
169
+ else
170
+ body << read_error_body(response)
171
+ end
172
+ end
173
+ end
174
+
175
+ Response.new(status, body.to_s, response_headers, url)
176
+ end
177
+
178
+ private def attempt_stream(
179
+ url : URI,
180
+ headers : HTTP::Headers,
181
+ destination : IO,
182
+ max_bytes : Int64?,
183
+ &before_body : HTTP::Status, HTTP::Headers, URI ->
184
+ ) : Response
185
+ status = HTTP::Status::OK
186
+ response_headers = HTTP::Headers.new
187
+
188
+ with_client(url) do |client|
189
+ client.exec(build_request(url, headers)) do |response|
190
+ status = response.status
191
+ response_headers = response.headers
192
+
193
+ unless status.success?
194
+ return Response.new(status, read_error_body(response), response_headers, url)
195
+ end
196
+
197
+ before_body.call(status, response_headers, url)
198
+ read_body(response, url, destination, max_bytes)
199
+ end
200
+ end
201
+
202
+ Response.new(status, "", response_headers, url)
203
+ end
204
+
205
+ private def read_body(
206
+ response : HTTP::Client::Response,
207
+ url : URI,
208
+ destination : IO,
209
+ max_bytes : Int64?,
210
+ ) : Nil
211
+ content_length = response.headers["Content-Length"]?.try(&.to_i64?)
212
+ if max_bytes && content_length && content_length > max_bytes
213
+ raise ResponseTooLargeError.new(url, max_bytes)
214
+ end
215
+
216
+ io = response.body_io?
217
+ return unless io
218
+
219
+ buffer = Bytes.new(16 * 1024)
220
+ total = 0_i64
221
+
222
+ loop do
223
+ read = io.read(buffer)
224
+ break if read.zero?
225
+
226
+ total += read
227
+ if max_bytes && total > max_bytes
228
+ raise ResponseTooLargeError.new(url, max_bytes)
229
+ end
230
+
231
+ destination.write(buffer[0, read])
232
+ end
233
+
234
+ if content_length && identity_encoded?(response) && total < content_length
235
+ raise IncompleteResponseError.new(url, content_length, total)
236
+ end
237
+ end
238
+
239
+ private def read_error_body(response : HTTP::Client::Response) : String
240
+ io = response.body_io?
241
+ return "" unless io
242
+
243
+ body = IO::Memory.new
244
+ buffer = Bytes.new(4 * 1024)
245
+ remaining = 64 * 1024
246
+
247
+ while remaining > 0
248
+ read = io.read(buffer[0, Math.min(buffer.size, remaining)])
249
+ break if read.zero?
250
+
251
+ body.write(buffer[0, read])
252
+ remaining -= read
253
+ end
254
+
255
+ body.to_s
256
+ end
257
+
258
+ private def with_client(url : URI, &)
259
+ client = build_client(url)
260
+
261
+ begin
262
+ yield client
263
+ rescue ex : IO::Error | Socket::Error
264
+ raise NetworkError.new(url, ex)
265
+ ensure
266
+ client.close
267
+ end
268
+ end
269
+
270
+ private def build_request(
271
+ url : URI,
272
+ headers : HTTP::Headers,
273
+ method : String = "GET",
274
+ body : String? = nil,
275
+ ) : HTTP::Request
276
+ request = HTTP::Request.new(method, request_target(url), headers)
277
+ request.body = body if body
278
+ request
279
+ end
280
+
281
+ private def request_target(url : URI) : String
282
+ encode_target(url.request_target)
283
+ end
284
+
285
+ private def encode_target(value : String) : String
286
+ String.build do |io|
287
+ bytes = value.to_slice
288
+ index = 0
289
+
290
+ while index < bytes.size
291
+ byte = bytes[index]
292
+
293
+ if byte == 0x25_u8 && index + 2 < bytes.size &&
294
+ hex_digit?(bytes[index + 1]) && hex_digit?(bytes[index + 2])
295
+ io.write_byte(byte)
296
+ io.write_byte(bytes[index + 1])
297
+ io.write_byte(bytes[index + 2])
298
+ index += 3
299
+ elsif target_byte?(byte)
300
+ io.write_byte(byte)
301
+ index += 1
302
+ else
303
+ io << '%'
304
+ io.write_byte(HEX.to_unsafe[byte >> 4])
305
+ io.write_byte(HEX.to_unsafe[byte & 0x0f])
306
+ index += 1
307
+ end
308
+ end
309
+ end
310
+ end
311
+
312
+ private def hex_digit?(byte : UInt8) : Bool
313
+ (0x30_u8 <= byte <= 0x39_u8) ||
314
+ (0x41_u8 <= byte <= 0x46_u8) ||
315
+ (0x61_u8 <= byte <= 0x66_u8)
316
+ end
317
+
318
+ private def target_byte?(byte : UInt8) : Bool
319
+ (0x30_u8 <= byte <= 0x39_u8) ||
320
+ (0x41_u8 <= byte <= 0x5a_u8) ||
321
+ (0x61_u8 <= byte <= 0x7a_u8) ||
322
+ "-._~!$&'()*+,;=:@/?".to_slice.includes?(byte)
323
+ end
324
+
325
+ private def identity_encoded?(response : HTTP::Client::Response) : Bool
326
+ encoding = response.headers["Content-Encoding"]?
327
+ encoding.nil? || encoding.downcase == "identity"
328
+ end
329
+
330
+ private def build_client(url : URI) : HTTP::Client
331
+ client = HTTP::Client.new(
332
+ url.host.not_nil!,
333
+ url.port,
334
+ tls: url.scheme == "https",
335
+ )
336
+ client.connect_timeout = @options.connect_timeout
337
+ client.read_timeout = @options.read_timeout
338
+ client.dns_timeout = @options.connect_timeout
339
+ client
340
+ end
341
+
342
+ private def initial_headers(extra : HTTP::Headers?) : HTTP::Headers
343
+ headers = HTTP::Headers.new
344
+ copy_headers(@options.headers, headers)
345
+
346
+ if extra
347
+ copy_headers(extra, headers)
348
+ end
349
+
350
+ headers["User-Agent"] ||= @options.user_agent
351
+ headers["Accept"] ||= "*/*"
352
+
353
+ headers
354
+ end
355
+
356
+ private def apply_cookies(
357
+ headers : HTTP::Headers,
358
+ url : URI,
359
+ extra_cookies : Hash(String, String),
360
+ include_explicit : Bool,
361
+ ) : Nil
362
+ headers.delete("Cookie")
363
+
364
+ explicit = {} of String => String
365
+ if include_explicit
366
+ @header_cookies.each do |name, value|
367
+ explicit[name] = value
368
+ end
369
+ extra_cookies.each do |name, value|
370
+ explicit[name] = value
371
+ end
372
+ @options.cookies.each do |name, value|
373
+ explicit[name] = value
374
+ end
375
+ end
376
+
377
+ jar = @cookies.cookies_for(url).reject { |cookie| explicit.has_key?(cookie.name) }
378
+ jar.sort_by! { |cookie| -(cookie.path || "/").size }
379
+
380
+ values = jar.map(&.to_cookie_header)
381
+ explicit.each do |name, value|
382
+ values << "#{name}=#{value}"
383
+ end
384
+
385
+ return if values.empty?
386
+
387
+ headers["Cookie"] = values.join("; ")
388
+ end
389
+
390
+ private def parse_cookie_values(values : Array(String)?) : Hash(String, String)
391
+ cookies = {} of String => String
392
+ return cookies unless values
393
+
394
+ values.each do |header|
395
+ header.split(';').each do |pair|
396
+ name, separator, value = pair.partition('=')
397
+ next if separator.empty? || name.strip.empty?
398
+
399
+ cookies[name.strip] = value.strip
400
+ end
401
+ end
402
+
403
+ cookies
404
+ end
405
+
406
+ private def copy_headers(source : HTTP::Headers, destination : HTTP::Headers) : Nil
407
+ source.each do |name, values|
408
+ values.each do |value|
409
+ destination.add(name, value)
410
+ end
411
+ end
412
+ end
413
+
414
+ private def redirected_headers(
415
+ headers : HTTP::Headers,
416
+ from : URI,
417
+ to : URI,
418
+ ) : HTTP::Headers
419
+ return headers if same_origin?(from, to)
420
+
421
+ copy = HTTP::Headers.new
422
+ headers.each do |name, values|
423
+ next if SENSITIVE_HEADERS.includes?(name.downcase)
424
+
425
+ values.each do |value|
426
+ copy.add(name, value)
427
+ end
428
+ end
429
+ copy
430
+ end
431
+
432
+ private def same_origin?(first : URI, second : URI) : Bool
433
+ first.scheme == second.scheme &&
434
+ first.host.try(&.downcase) == second.host.try(&.downcase) &&
435
+ effective_port(first) == effective_port(second)
436
+ end
437
+
438
+ private def effective_port(url : URI) : Int32
439
+ url.port || (url.scheme == "https" ? 443 : 80)
440
+ end
441
+
442
+ private def redirect_location(response : Response) : String?
443
+ return nil unless REDIRECT_STATUSES.includes?(response.status.code)
444
+ response.headers["Location"]?
445
+ end
446
+
447
+ private def retryable?(status : HTTP::Status) : Bool
448
+ RETRY_STATUSES.includes?(status.code)
449
+ end
450
+
451
+ private def retry_wait(attempt : Int32) : Time::Span
452
+ @options.retry_wait(attempt)
453
+ end
454
+
455
+ private def parse_url(url : String | URI) : URI
456
+ uri = url.is_a?(URI) ? url : URI.parse(url)
457
+ validate_http_url!(uri)
458
+ uri
459
+ rescue ex : URI::Error
460
+ raise InvalidUrlError.new(url.to_s, ex.message || "malformed URL")
461
+ end
462
+
463
+ private def validate_http_url!(url : URI) : Nil
464
+ unless url.scheme == "http" || url.scheme == "https"
465
+ raise InvalidUrlError.new(url.to_s, "only HTTP and HTTPS are supported")
466
+ end
467
+
468
+ if url.host.nil? || url.host.not_nil!.empty?
469
+ raise InvalidUrlError.new(url.to_s, "missing host")
470
+ end
471
+ end
472
+
473
+ private def validate_options! : Nil
474
+ if @options.retries < 0
475
+ raise ArgumentError.new("retries must not be negative")
476
+ end
477
+
478
+ if @options.retries > 10
479
+ raise ArgumentError.new("retries must not exceed 10")
480
+ end
481
+
482
+ if @options.max_redirects < 0
483
+ raise ArgumentError.new("max_redirects must not be negative")
484
+ end
485
+
486
+ if @options.max_response_bytes <= 0
487
+ raise ArgumentError.new("max_response_bytes must be positive")
488
+ end
489
+
490
+ if @options.connect_timeout <= Time::Span.zero
491
+ raise ArgumentError.new("connect_timeout must be positive")
492
+ end
493
+
494
+ if @options.read_timeout <= Time::Span.zero
495
+ raise ArgumentError.new("read_timeout must be positive")
496
+ end
497
+
498
+ if @options.user_agent.empty?
499
+ raise ArgumentError.new("user_agent must not be empty")
500
+ end
501
+ end
502
+ end
503
+ end
@@ -0,0 +1,28 @@
1
+ require "uri"
2
+
3
+ module Rill
4
+ module HttpUrl
5
+ extend self
6
+
7
+ def absolute(value : String?) : URI?
8
+ return nil unless value
9
+
10
+ validate(URI.parse(value))
11
+ rescue URI::Error
12
+ nil
13
+ end
14
+
15
+ def resolve(base : URI, value : String) : URI?
16
+ validate(base.resolve(value))
17
+ rescue URI::Error
18
+ nil
19
+ end
20
+
21
+ private def validate(uri : URI) : URI?
22
+ return nil unless uri.scheme == "http" || uri.scheme == "https"
23
+ return nil if uri.host.nil?
24
+
25
+ uri
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,66 @@
1
+ require "json"
2
+
3
+ module Rill
4
+ module JsonValue
5
+ extend self
6
+
7
+ def string(value : JSON::Any?) : String?
8
+ return nil unless value
9
+
10
+ if text = value.as_s?
11
+ text
12
+ elsif array = value.as_a?
13
+ array.each do |item|
14
+ found = item.as_s?
15
+ return found if found
16
+ end
17
+ nil
18
+ elsif integer = value.as_i64?
19
+ integer.to_s
20
+ elsif float = value.as_f?
21
+ float.to_s
22
+ end
23
+ end
24
+
25
+ def integer(value : JSON::Any?) : Int64?
26
+ return nil unless value
27
+
28
+ if text = value.as_s?
29
+ text.to_i64?
30
+ else
31
+ value.as_i64?
32
+ end
33
+ end
34
+
35
+ def number(value : JSON::Any?) : Float64?
36
+ return nil unless value
37
+
38
+ parsed = if text = value.as_s?
39
+ text.to_f?
40
+ elsif integer = value.as_i64?
41
+ integer.to_f
42
+ elsif float = value.as_f?
43
+ float
44
+ end
45
+
46
+ parsed if parsed && parsed.finite?
47
+ end
48
+
49
+ def positive_number(value : JSON::Any?) : Float64?
50
+ parsed = number(value)
51
+ parsed if parsed && parsed > 0
52
+ end
53
+
54
+ def dimension(value : JSON::Any?) : Int32?
55
+ parsed = integer(value)
56
+ return nil unless parsed && 0 < parsed <= 100_000
57
+
58
+ parsed.to_i32
59
+ end
60
+
61
+ def seconds(value : JSON::Any?) : Float64?
62
+ parsed = number(value)
63
+ parsed if parsed && parsed >= 0
64
+ end
65
+ end
66
+ end