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,403 @@
1
+ require "../cancellation"
2
+ require "../client"
3
+ require "../download_options"
4
+ require "../download_plan"
5
+ require "../download_progress"
6
+ require "../errors"
7
+ require "../extractor_registry"
8
+ require "../format_selector"
9
+ require "../media"
10
+ require "../request_options"
11
+ require "./job"
12
+ require "./memory"
13
+ require "./status"
14
+ require "./sync"
15
+
16
+ module Rill::CAPI::WorkerPool
17
+ extend self
18
+
19
+ MAX_WORKERS = 64
20
+
21
+ @@pool : Pool?
22
+ @@shutting_down = Atomic(Bool).new(false)
23
+
24
+ def start(worker_count : Int32) : Nil
25
+ @@shutting_down.set(false)
26
+ pool = Pool.new(worker_count)
27
+ @@pool = pool
28
+ pool.start
29
+ end
30
+
31
+ def shutting_down? : Bool
32
+ @@shutting_down.get
33
+ end
34
+
35
+ def create_job(url : UInt8*, options_json : UInt8*) : JobPtr
36
+ job = Job.create_extraction(url, options_json)
37
+ return job if job.null? || Job.done?(job)
38
+
39
+ submit(job)
40
+ job
41
+ end
42
+
43
+ def create_download_job(url : UInt8*, output_path : UInt8*, options_json : UInt8*) : JobPtr
44
+ job = Job.create_download(url, output_path, options_json)
45
+ return job if job.null? || Job.done?(job)
46
+
47
+ submit(job)
48
+ job
49
+ end
50
+
51
+ private def submit(job : JobPtr) : Nil
52
+ pool = @@pool
53
+ if pool
54
+ pool.submit(job)
55
+ else
56
+ finish_shutdown(job)
57
+ end
58
+ end
59
+
60
+ def finish_shutdown(job : JobPtr) : Nil
61
+ if job.value.kind == Rill::CAPI::Kind::DOWNLOAD
62
+ Job.finish_download(
63
+ job,
64
+ Status::SHUTDOWN,
65
+ Rill::CAPI::DownloadState::CANCELLED,
66
+ Pointer(UInt8).null,
67
+ Pointer(UInt8).null,
68
+ )
69
+ else
70
+ Job.finish(
71
+ job,
72
+ Status::SHUTDOWN,
73
+ Pointer(UInt8).null,
74
+ Pointer(UInt8).null,
75
+ Pointer(UInt8).null,
76
+ )
77
+ end
78
+ end
79
+
80
+ def shutdown : Nil
81
+ @@shutting_down.set(true)
82
+ pool = @@pool
83
+ @@pool = nil
84
+ pool.try(&.shutdown)
85
+ end
86
+
87
+ class Pool
88
+ @head : JobPtr
89
+ @tail : JobPtr
90
+ @mutex : Sync::Mutex*
91
+ @cond : Sync::Cond*
92
+ @done_cond : Sync::Cond*
93
+
94
+ def initialize(@worker_count : Int32)
95
+ @head = JobPtr.null
96
+ @tail = JobPtr.null
97
+ @shutdown = false
98
+ @running = @worker_count
99
+ @workers = [] of Fiber::ExecutionContext::Isolated
100
+ @mutex = allocate_mutex
101
+ @cond = allocate_cond
102
+ @done_cond = allocate_cond
103
+
104
+ if @mutex.null? || @cond.null? || @done_cond.null?
105
+ raise "failed to allocate sync primitives"
106
+ end
107
+ end
108
+
109
+ def start : Nil
110
+ @worker_count.times do |index|
111
+ @workers << Fiber::ExecutionContext::Isolated.new("rill-worker-#{index}") { work }
112
+ end
113
+ end
114
+
115
+ def submit(job : JobPtr) : Nil
116
+ lock
117
+
118
+ unless @shutdown
119
+ enqueue(job)
120
+ Sync.signal(@cond)
121
+ unlock
122
+ return
123
+ end
124
+
125
+ unlock
126
+ WorkerPool.finish_shutdown(job)
127
+ end
128
+
129
+ def shutdown : Nil
130
+ lock
131
+
132
+ unless @shutdown
133
+ @shutdown = true
134
+ Sync.broadcast(@cond)
135
+ fail_pending
136
+ end
137
+
138
+ while @running > 0
139
+ Sync.wait(@done_cond, @mutex)
140
+ end
141
+
142
+ unlock
143
+ destroy_sync
144
+ end
145
+
146
+ private def work : Nil
147
+ loop do
148
+ job = pop
149
+ break if job.null?
150
+
151
+ begin
152
+ process(job)
153
+ rescue ex
154
+ finish_error(job, ex)
155
+ end
156
+ end
157
+ ensure
158
+ worker_finished
159
+ end
160
+
161
+ private def pop : JobPtr
162
+ lock
163
+
164
+ while @head.null? && !@shutdown
165
+ Sync.wait(@cond, @mutex)
166
+ end
167
+
168
+ job = @head
169
+ unless job.null?
170
+ dequeue
171
+ end
172
+
173
+ unlock
174
+ job
175
+ end
176
+
177
+ private def process(job : JobPtr) : Nil
178
+ if job.value.kind == Rill::CAPI::Kind::DOWNLOAD
179
+ process_download(job)
180
+ else
181
+ process_extraction(job)
182
+ end
183
+ end
184
+
185
+ private def process_extraction(job : JobPtr) : Nil
186
+ options = options_for(job)
187
+ media = ExtractorRegistry.default.extract(String.new(job.value.url), options)
188
+ Job.finish(
189
+ job,
190
+ Status::OK,
191
+ Memory.copy_string(media.to_pretty_json),
192
+ Pointer(UInt8).null,
193
+ Pointer(UInt8).null,
194
+ )
195
+ rescue ex
196
+ finish_error(job, ex)
197
+ end
198
+
199
+ private def process_download(job : JobPtr) : Nil
200
+ Job.mark_running(job)
201
+ cancellation = Cancellation.watch do
202
+ job.value.cancel_requested != 0 || WorkerPool.shutting_down?
203
+ end
204
+ cancellation.check!
205
+
206
+ options = DownloadOptions.from_json(optional_string(job.value.options_json))
207
+ client = Client.new(options.request)
208
+ result = client.extract(String.new(job.value.url))
209
+
210
+ unless result.is_a?(Media)
211
+ raise DownloadError.new("URL is a playlist")
212
+ end
213
+
214
+ plan = FormatSelector.plan(result.formats, options.format_id, options.preference)
215
+ unless plan
216
+ raise DownloadError.new("no downloadable formats")
217
+ end
218
+
219
+ progress = ->(value : DownloadProgress) do
220
+ Job.update_progress(job, value.downloaded_bytes, value.total_bytes)
221
+ end
222
+
223
+ client.download(
224
+ plan,
225
+ String.new(job.value.output_path),
226
+ cancellation,
227
+ progress,
228
+ resume: options.resume,
229
+ overwrite: options.overwrite,
230
+ media_backend: options.media_backend,
231
+ )
232
+
233
+ Job.finish_download(
234
+ job,
235
+ Status::OK,
236
+ Rill::CAPI::DownloadState::COMPLETED,
237
+ Pointer(UInt8).null,
238
+ Pointer(UInt8).null,
239
+ )
240
+ rescue ex : CancelledError
241
+ Job.finish_download(
242
+ job,
243
+ Status::CANCELLED,
244
+ Rill::CAPI::DownloadState::CANCELLED,
245
+ Memory.copy_string("cancelled"),
246
+ Memory.copy_string("cancelled"),
247
+ )
248
+ rescue ex
249
+ Job.finish_download(
250
+ job,
251
+ status_for(ex),
252
+ Rill::CAPI::DownloadState::FAILED,
253
+ Memory.copy_string(ex.message || ex.class.name),
254
+ Memory.copy_string(error_code_for(ex)),
255
+ )
256
+ end
257
+
258
+ private def optional_string(pointer : UInt8*) : String?
259
+ pointer.null? ? nil : String.new(pointer)
260
+ end
261
+
262
+ private def options_for(job : JobPtr) : RequestOptions
263
+ if job.value.options_json.null?
264
+ RequestOptions.new
265
+ else
266
+ RequestOptions.from_json(String.new(job.value.options_json))
267
+ end
268
+ end
269
+
270
+ private def finish_error(job : JobPtr, error : Exception) : Nil
271
+ message = error.message || error.class.name
272
+
273
+ if job.value.kind == Rill::CAPI::Kind::DOWNLOAD
274
+ Job.finish_download(
275
+ job,
276
+ status_for(error),
277
+ Rill::CAPI::DownloadState::FAILED,
278
+ Memory.copy_string(message),
279
+ Memory.copy_string(error_code_for(error)),
280
+ )
281
+ else
282
+ Job.finish(
283
+ job,
284
+ status_for(error),
285
+ Pointer(UInt8).null,
286
+ Memory.copy_string(message),
287
+ Memory.copy_string(error_code_for(error)),
288
+ )
289
+ end
290
+ end
291
+
292
+ private def status_for(error : Exception) : Int32
293
+ case error
294
+ when Rill::CancelledError
295
+ Status::CANCELLED
296
+ when Rill::InvalidOptionsError, Rill::InvalidUrlError, ArgumentError
297
+ Status::INVALID_ARGUMENT
298
+ when Rill::Error
299
+ Status::EXTRACTION_ERROR
300
+ else
301
+ Status::INTERNAL_ERROR
302
+ end
303
+ end
304
+
305
+ private def error_code_for(error : Exception) : String
306
+ case error
307
+ when Rill::UnsupportedUrlError
308
+ "unsupported_url"
309
+ when Rill::MediaUnavailableError
310
+ "media_unavailable"
311
+ when Rill::InvalidExtractionError
312
+ "invalid_extraction"
313
+ when Rill::HTTPError
314
+ "http_error"
315
+ when Rill::InvalidUrlError
316
+ "invalid_url"
317
+ when Rill::ResponseTooLargeError
318
+ "response_too_large"
319
+ when Rill::TooManyRedirectsError
320
+ "too_many_redirects"
321
+ when Rill::NetworkError
322
+ "network_error"
323
+ when Rill::InvalidOptionsError, ArgumentError
324
+ "invalid_options"
325
+ when Rill::MediaBackendUnavailableError
326
+ "media_backend_unavailable"
327
+ when Rill::MediaMuxError
328
+ "media_mux_error"
329
+ when Rill::DownloadError
330
+ "download_error"
331
+ else
332
+ "internal"
333
+ end
334
+ end
335
+
336
+ private def enqueue(job : JobPtr) : Nil
337
+ job.value.next_job = JobPtr.null
338
+
339
+ if @tail.null?
340
+ @head = job
341
+ else
342
+ @tail.value.next_job = job
343
+ end
344
+
345
+ @tail = job
346
+ end
347
+
348
+ private def dequeue : Nil
349
+ job = @head
350
+ return if job.null?
351
+
352
+ @head = job.value.next_job
353
+ @tail = JobPtr.null if @head.null?
354
+ end
355
+
356
+ private def fail_pending : Nil
357
+ loop do
358
+ job = @head
359
+ break if job.null?
360
+
361
+ dequeue
362
+
363
+ Sync.lock(job.value.mutex)
364
+ job.value.status = Status::SHUTDOWN
365
+ if job.value.kind == Rill::CAPI::Kind::DOWNLOAD
366
+ job.value.download_state = Rill::CAPI::DownloadState::CANCELLED
367
+ end
368
+ job.value.done = 1
369
+ Sync.signal(job.value.cond)
370
+ Sync.unlock(job.value.mutex)
371
+ end
372
+ end
373
+
374
+ private def worker_finished : Nil
375
+ lock
376
+ @running -= 1
377
+ Sync.broadcast(@done_cond)
378
+ unlock
379
+ end
380
+
381
+ private def lock : Nil
382
+ Sync.lock(@mutex)
383
+ end
384
+
385
+ private def unlock : Nil
386
+ Sync.unlock(@mutex)
387
+ end
388
+
389
+ private def allocate_mutex : Sync::Mutex*
390
+ Sync.create_mutex
391
+ end
392
+
393
+ private def allocate_cond : Sync::Cond*
394
+ Sync.create_cond
395
+ end
396
+
397
+ private def destroy_sync : Nil
398
+ Sync.destroy_cond(@done_cond)
399
+ Sync.destroy_cond(@cond)
400
+ Sync.destroy_mutex(@mutex)
401
+ end
402
+ end
403
+ end
@@ -0,0 +1,29 @@
1
+ require "./errors"
2
+
3
+ module Rill
4
+ class Cancellation
5
+ @cancelled : Atomic(Bool)
6
+ @checker : Proc(Bool)?
7
+
8
+ def initialize(@checker : Proc(Bool)? = nil)
9
+ @cancelled = Atomic(Bool).new(false)
10
+ end
11
+
12
+ def self.watch(&block : -> Bool) : self
13
+ new(block)
14
+ end
15
+
16
+ def cancel! : Nil
17
+ @cancelled.set(true)
18
+ end
19
+
20
+ def cancelled? : Bool
21
+ checker = @checker
22
+ checker ? checker.call : @cancelled.get
23
+ end
24
+
25
+ def check! : Nil
26
+ raise CancelledError.new if cancelled?
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,52 @@
1
+ require "path"
2
+ require "uri"
3
+
4
+ require "./cancellation"
5
+ require "./downloader"
6
+ require "./download_plan"
7
+ require "./download_progress"
8
+ require "./extractor_registry"
9
+ require "./format"
10
+ require "./http_client"
11
+ require "./media"
12
+ require "./media_muxer"
13
+ require "./playlist"
14
+ require "./request_options"
15
+
16
+ module Rill
17
+ class Client
18
+ getter options : RequestOptions
19
+ getter http : HTTPClient
20
+ getter registry : ExtractorRegistry
21
+
22
+ def initialize(
23
+ @options : RequestOptions = RequestOptions.new,
24
+ @registry : ExtractorRegistry = ExtractorRegistry.default,
25
+ )
26
+ @http = HTTPClient.new(@options)
27
+ end
28
+
29
+ def extract(url : String | URI) : Media | Playlist
30
+ @registry.extract(url, @http)
31
+ end
32
+
33
+ def download(
34
+ source : DownloadPlan | Format | String | URI,
35
+ target : String | Path,
36
+ cancellation : Cancellation? = nil,
37
+ on_progress : Proc(DownloadProgress, Nil)? = nil,
38
+ resume : Bool = true,
39
+ overwrite : Bool = false,
40
+ media_backend : MediaMuxer::Backend = MediaMuxer.backend_from_environment,
41
+ ) : Path
42
+ downloader = Downloader.new(
43
+ @options,
44
+ @http,
45
+ resume: resume,
46
+ overwrite: overwrite,
47
+ media_muxer: MediaMuxer.new(media_backend),
48
+ )
49
+ downloader.download(source, target, cancellation, on_progress)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,130 @@
1
+ require "http/cookie"
2
+ require "uri"
3
+
4
+ module Rill
5
+ class CookieJar
6
+ private struct StoredCookie
7
+ getter cookie : HTTP::Cookie
8
+ getter host_only : Bool
9
+
10
+ def initialize(@cookie : HTTP::Cookie, @host_only : Bool)
11
+ end
12
+ end
13
+
14
+ def initialize
15
+ @cookies = [] of StoredCookie
16
+ end
17
+
18
+ def add(headers : HTTP::Headers, url : URI) : Nil
19
+ values = headers.get?("Set-Cookie")
20
+ return if values.nil? || values.empty?
21
+
22
+ host = url.host.try(&.downcase)
23
+ return unless host
24
+
25
+ values.each do |value|
26
+ cookie = parse_cookie(value)
27
+ next unless cookie
28
+ next unless cookie.valid?
29
+
30
+ domain = normalized_domain(cookie.domain)
31
+ if domain
32
+ next unless settable_domain?(host, domain)
33
+
34
+ cookie.domain = domain
35
+ host_only = false
36
+ else
37
+ cookie.domain = host
38
+ host_only = true
39
+ end
40
+
41
+ cookie.path ||= default_path(url.path)
42
+
43
+ if cookie.expired?
44
+ delete_matching(cookie)
45
+ next
46
+ end
47
+
48
+ store(StoredCookie.new(cookie, host_only))
49
+ end
50
+ end
51
+
52
+ def cookies_for(url : URI) : Array(HTTP::Cookie)
53
+ now = Time.utc
54
+ @cookies.reject!(&.cookie.expired?(now))
55
+ @cookies.select { |stored| matches?(stored, url) }.map(&.cookie)
56
+ end
57
+
58
+ private def parse_cookie(value : String) : HTTP::Cookie?
59
+ HTTP::Cookie::Parser.parse_set_cookie(value)
60
+ rescue
61
+ nil
62
+ end
63
+
64
+ private def store(stored : StoredCookie) : Nil
65
+ cookie = stored.cookie
66
+ delete_matching(cookie)
67
+ @cookies << stored
68
+ end
69
+
70
+ private def delete_matching(cookie : HTTP::Cookie) : Nil
71
+ @cookies.reject! do |existing|
72
+ existing.cookie.name == cookie.name &&
73
+ existing.cookie.domain.try(&.downcase) == cookie.domain.try(&.downcase) &&
74
+ existing.cookie.path == cookie.path
75
+ end
76
+ end
77
+
78
+ private def matches?(stored : StoredCookie, url : URI) : Bool
79
+ host = url.host.try(&.downcase)
80
+ return false unless host
81
+
82
+ cookie = stored.cookie
83
+ return false unless domain_matches?(host, cookie.domain, stored.host_only)
84
+ return false if cookie.secure && url.scheme != "https"
85
+
86
+ path_match?(url.path, cookie.path || "/")
87
+ end
88
+
89
+ private def domain_matches?(host : String, domain : String?, host_only : Bool) : Bool
90
+ return false unless domain
91
+
92
+ normalized = normalized_domain(domain)
93
+ return false unless normalized
94
+ return host == normalized if host_only
95
+
96
+ host == normalized || host.ends_with?(".#{normalized}")
97
+ end
98
+
99
+ private def settable_domain?(host : String, domain : String) : Bool
100
+ return true if host == domain
101
+ return false unless host.ends_with?(".#{domain}")
102
+ return false unless domain.includes?('.')
103
+
104
+ true
105
+ end
106
+
107
+ private def normalized_domain(domain : String?) : String?
108
+ return nil unless domain
109
+
110
+ value = domain.strip.downcase.lchop('.')
111
+ value.empty? ? nil : value
112
+ end
113
+
114
+ private def path_match?(request_path : String, cookie_path : String) : Bool
115
+ return true if request_path == cookie_path
116
+ return true if request_path.starts_with?(cookie_path) && cookie_path.ends_with?('/')
117
+
118
+ request_path.starts_with?("#{cookie_path}/")
119
+ end
120
+
121
+ private def default_path(path : String) : String
122
+ return "/" unless path.starts_with?('/')
123
+
124
+ index = path.rindex('/')
125
+ return "/" unless index && index > 0
126
+
127
+ path[0, index]
128
+ end
129
+ end
130
+ end