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.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +64 -2
- package/binding.gyp +31 -0
- package/index.d.ts +45 -0
- package/index.js +112 -0
- package/native/darwin-arm64/librill.dylib +0 -0
- package/native/darwin-arm64/rill-media +0 -0
- package/native/darwin-arm64/rill_node.node +0 -0
- package/native/darwin-x64/librill.dylib +0 -0
- package/native/darwin-x64/rill-media +0 -0
- package/native/darwin-x64/rill_node.node +0 -0
- package/native/linux-arm64-gnu/librill.so +0 -0
- package/native/linux-arm64-gnu/rill-media +0 -0
- package/native/linux-arm64-gnu/rill_node.node +0 -0
- package/native/linux-x64-gnu/librill.so +0 -0
- package/native/linux-x64-gnu/rill-media +0 -0
- package/native/linux-x64-gnu/rill_node.node +0 -0
- package/native-src/include/rill.h +90 -0
- package/native-src/include/rill_media.h +22 -0
- package/native-src/shard.yml +9 -0
- package/native-src/src/c_api.cr +194 -0
- package/native-src/src/cli.cr +637 -0
- package/native-src/src/rill/c_api/job.cr +336 -0
- package/native-src/src/rill/c_api/memory.cr +32 -0
- package/native-src/src/rill/c_api/status.cr +25 -0
- package/native-src/src/rill/c_api/sync.cr +125 -0
- package/native-src/src/rill/c_api/worker_pool.cr +403 -0
- package/native-src/src/rill/cancellation.cr +29 -0
- package/native-src/src/rill/client.cr +52 -0
- package/native-src/src/rill/cookie_jar.cr +130 -0
- package/native-src/src/rill/dash/manifest.cr +279 -0
- package/native-src/src/rill/dash/parser.cr +227 -0
- package/native-src/src/rill/download_options.cr +96 -0
- package/native-src/src/rill/download_plan.cr +27 -0
- package/native-src/src/rill/download_progress.cr +16 -0
- package/native-src/src/rill/downloader.cr +662 -0
- package/native-src/src/rill/errors.cr +155 -0
- package/native-src/src/rill/extractor.cr +18 -0
- package/native-src/src/rill/extractor_registry.cr +98 -0
- package/native-src/src/rill/extractors/bandcamp.cr +265 -0
- package/native-src/src/rill/extractors/dash.cr +77 -0
- package/native-src/src/rill/extractors/direct_media.cr +73 -0
- package/native-src/src/rill/extractors/generic.cr +441 -0
- package/native-src/src/rill/extractors/hls.cr +202 -0
- package/native-src/src/rill/extractors/internet_archive.cr +180 -0
- package/native-src/src/rill/extractors/m3u.cr +81 -0
- package/native-src/src/rill/extractors/media_ccc_de.cr +219 -0
- package/native-src/src/rill/extractors/mixcloud.cr +495 -0
- package/native-src/src/rill/extractors/peer_tube.cr +226 -0
- package/native-src/src/rill/extractors/rumble.cr +325 -0
- package/native-src/src/rill/extractors/soundcloud.cr +553 -0
- package/native-src/src/rill/extractors/spotify.cr +184 -0
- package/native-src/src/rill/extractors/twitch_clip.cr +273 -0
- package/native-src/src/rill/extractors/youtube.cr +520 -0
- package/native-src/src/rill/format.cr +82 -0
- package/native-src/src/rill/format_selector.cr +143 -0
- package/native-src/src/rill/hls/parser.cr +257 -0
- package/native-src/src/rill/hls/playlist.cr +100 -0
- package/native-src/src/rill/http_client.cr +503 -0
- package/native-src/src/rill/http_url.cr +28 -0
- package/native-src/src/rill/json_value.cr +66 -0
- package/native-src/src/rill/media.cr +151 -0
- package/native-src/src/rill/media_muxer.cr +333 -0
- package/native-src/src/rill/mime_type.cr +58 -0
- package/native-src/src/rill/output_file_name.cr +91 -0
- package/native-src/src/rill/playlist.cr +109 -0
- package/native-src/src/rill/request_options.cr +179 -0
- package/native-src/src/rill/segment_assembly.cr +70 -0
- package/native-src/src/rill/timestamp.cr +34 -0
- package/native-src/src/rill/version.cr +3 -0
- package/native-src/src/rill.cr +47 -0
- package/package.json +48 -4
- package/scripts/build-native.js +46 -0
- package/scripts/install-gyp.js +10 -0
- package/scripts/install.js +19 -0
- package/scripts/lib.js +1 -0
- package/scripts/libdir.js +23 -0
- package/scripts/package-native.js +35 -0
- package/scripts/platform.js +41 -0
- package/scripts/prepack.js +47 -0
- 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
|