rill-node 0.0.0-stage → 2026.11.15
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,662 @@
|
|
|
1
|
+
require "file"
|
|
2
|
+
require "json"
|
|
3
|
+
require "path"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
require "./cancellation"
|
|
7
|
+
require "./dash/parser"
|
|
8
|
+
require "./download_plan"
|
|
9
|
+
require "./download_progress"
|
|
10
|
+
require "./errors"
|
|
11
|
+
require "./format"
|
|
12
|
+
require "./hls/parser"
|
|
13
|
+
require "./hls/playlist"
|
|
14
|
+
require "./http_client"
|
|
15
|
+
require "./media_muxer"
|
|
16
|
+
require "./segment_assembly"
|
|
17
|
+
require "./request_options"
|
|
18
|
+
|
|
19
|
+
module Rill
|
|
20
|
+
class Downloader
|
|
21
|
+
PART_SUFFIX = ".part"
|
|
22
|
+
META_SUFFIX = ".meta"
|
|
23
|
+
CONTENT_RANGE = /\Abytes (\d+)-(\d+)\/(\d+|\*)\z/
|
|
24
|
+
PROGRESS_INTERVAL = 100.milliseconds
|
|
25
|
+
|
|
26
|
+
class ProgressWriter < IO
|
|
27
|
+
property total : Int64?
|
|
28
|
+
|
|
29
|
+
def initialize(
|
|
30
|
+
@file : IO,
|
|
31
|
+
@path : String,
|
|
32
|
+
@downloaded : Int64,
|
|
33
|
+
@cancellation : Cancellation?,
|
|
34
|
+
@callback : Proc(DownloadProgress, Nil)?,
|
|
35
|
+
)
|
|
36
|
+
@last_emit = Time.instant
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def base_offset=(value : Int64) : Nil
|
|
40
|
+
@downloaded = value
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def read(slice : Bytes) : Int32
|
|
44
|
+
raise IO::Error.new("progress writer is not readable")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def write(slice : Bytes) : Nil
|
|
48
|
+
@cancellation.try(&.check!)
|
|
49
|
+
|
|
50
|
+
begin
|
|
51
|
+
@file.write(slice)
|
|
52
|
+
rescue ex : IO::Error
|
|
53
|
+
raise DownloadWriteError.new(@path, ex)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@downloaded += slice.size
|
|
57
|
+
emit(force: false)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def emit(force : Bool) : Nil
|
|
61
|
+
callback = @callback
|
|
62
|
+
return unless callback
|
|
63
|
+
|
|
64
|
+
now = Time.instant
|
|
65
|
+
return unless force || now - @last_emit >= PROGRESS_INTERVAL
|
|
66
|
+
|
|
67
|
+
@last_emit = now
|
|
68
|
+
callback.call(DownloadProgress.new(@downloaded, @total))
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
@options : RequestOptions
|
|
73
|
+
@http : HTTPClient
|
|
74
|
+
@media_muxer : MediaMuxer
|
|
75
|
+
|
|
76
|
+
def initialize(
|
|
77
|
+
options : RequestOptions = RequestOptions.new,
|
|
78
|
+
http : HTTPClient? = nil,
|
|
79
|
+
@resume : Bool = true,
|
|
80
|
+
@overwrite : Bool = false,
|
|
81
|
+
@media_muxer : MediaMuxer = MediaMuxer.new,
|
|
82
|
+
)
|
|
83
|
+
@options = options
|
|
84
|
+
@http = http || HTTPClient.new(options)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def download(
|
|
88
|
+
source : Format | String | URI,
|
|
89
|
+
target : String | Path,
|
|
90
|
+
cancellation : Cancellation? = nil,
|
|
91
|
+
on_progress : Proc(DownloadProgress, Nil)? = nil,
|
|
92
|
+
) : Path
|
|
93
|
+
download_source(
|
|
94
|
+
source,
|
|
95
|
+
target,
|
|
96
|
+
cancellation,
|
|
97
|
+
on_progress,
|
|
98
|
+
check_target: true,
|
|
99
|
+
overwrite: @overwrite,
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def download(
|
|
104
|
+
plan : DownloadPlan,
|
|
105
|
+
target : String | Path,
|
|
106
|
+
cancellation : Cancellation? = nil,
|
|
107
|
+
on_progress : Proc(DownloadProgress, Nil)? = nil,
|
|
108
|
+
) : Path
|
|
109
|
+
download_plan(plan, target, cancellation, on_progress)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private def download_source(
|
|
113
|
+
source : Format | String | URI,
|
|
114
|
+
target : String | Path,
|
|
115
|
+
cancellation : Cancellation?,
|
|
116
|
+
on_progress : Proc(DownloadProgress, Nil)?,
|
|
117
|
+
check_target : Bool,
|
|
118
|
+
overwrite : Bool,
|
|
119
|
+
) : Path
|
|
120
|
+
url = case source
|
|
121
|
+
when Format then source.url
|
|
122
|
+
else source
|
|
123
|
+
end
|
|
124
|
+
format = source.is_a?(Format) ? source : nil
|
|
125
|
+
target_path = Path.new(target.to_s)
|
|
126
|
+
part_path = Path.new("#{target_path}#{PART_SUFFIX}")
|
|
127
|
+
ensure_not_symlink(part_path)
|
|
128
|
+
ensure_not_symlink(meta_path(part_path))
|
|
129
|
+
|
|
130
|
+
if check_target && File.exists?(target_path) && !overwrite
|
|
131
|
+
raise FileExistsError.new(target_path.to_s)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
if format.try(&.protocol.hls?)
|
|
135
|
+
remove_part(part_path)
|
|
136
|
+
transfer_hls(url, part_path, cancellation, on_progress)
|
|
137
|
+
elsif format.try(&.protocol.dash?)
|
|
138
|
+
remove_part(part_path)
|
|
139
|
+
transfer_dash(url, part_path, format.not_nil!.format_id, cancellation, on_progress)
|
|
140
|
+
else
|
|
141
|
+
remove_part(part_path) unless @resume
|
|
142
|
+
chunk_size = format.try(&.range_chunk_size)
|
|
143
|
+
total = format.try(&.filesize)
|
|
144
|
+
|
|
145
|
+
if chunk_size && total && total > 0
|
|
146
|
+
transfer_chunked(url, part_path, total, chunk_size, cancellation, on_progress)
|
|
147
|
+
else
|
|
148
|
+
transfer(url, part_path, cancellation, on_progress)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
publish(part_path, target_path, overwrite)
|
|
153
|
+
target_path
|
|
154
|
+
rescue ex : File::Error
|
|
155
|
+
raise DownloadWriteError.new(target_path.to_s, ex)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private def download_plan(
|
|
159
|
+
plan : DownloadPlan,
|
|
160
|
+
target : String | Path,
|
|
161
|
+
cancellation : Cancellation?,
|
|
162
|
+
on_progress : Proc(DownloadProgress, Nil)?,
|
|
163
|
+
) : Path
|
|
164
|
+
audio = plan.audio
|
|
165
|
+
unless audio
|
|
166
|
+
return download_source(
|
|
167
|
+
plan.stream,
|
|
168
|
+
target,
|
|
169
|
+
cancellation,
|
|
170
|
+
on_progress,
|
|
171
|
+
check_target: true,
|
|
172
|
+
overwrite: @overwrite,
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
target_path = Path.new(target.to_s)
|
|
177
|
+
if File.exists?(target_path) && !@overwrite
|
|
178
|
+
raise FileExistsError.new(target_path.to_s)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
video_path = Path.new("#{target_path}.video")
|
|
182
|
+
audio_path = Path.new("#{target_path}.audio")
|
|
183
|
+
completed = false
|
|
184
|
+
|
|
185
|
+
begin
|
|
186
|
+
download_part(plan.stream, video_path, cancellation, on_progress)
|
|
187
|
+
download_part(audio, audio_path, cancellation, on_progress)
|
|
188
|
+
@media_muxer.merge(video_path, audio_path, target_path, overwrite: @overwrite)
|
|
189
|
+
completed = true
|
|
190
|
+
ensure
|
|
191
|
+
if completed
|
|
192
|
+
File.delete(video_path) if File.exists?(video_path)
|
|
193
|
+
File.delete(audio_path) if File.exists?(audio_path)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
target_path
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private def download_part(
|
|
201
|
+
format : Format,
|
|
202
|
+
path : Path,
|
|
203
|
+
cancellation : Cancellation?,
|
|
204
|
+
on_progress : Proc(DownloadProgress, Nil)?,
|
|
205
|
+
) : Nil
|
|
206
|
+
if format.filesize && File.exists?(path) && File.size(path) == format.filesize
|
|
207
|
+
return
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
download_source(
|
|
211
|
+
format,
|
|
212
|
+
path,
|
|
213
|
+
cancellation,
|
|
214
|
+
on_progress,
|
|
215
|
+
check_target: false,
|
|
216
|
+
overwrite: true,
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private def transfer_chunked(
|
|
221
|
+
url : Format | String | URI,
|
|
222
|
+
part_path : Path,
|
|
223
|
+
total : Int64,
|
|
224
|
+
chunk_size : Int64,
|
|
225
|
+
cancellation : Cancellation?,
|
|
226
|
+
on_progress : Proc(DownloadProgress, Nil)?,
|
|
227
|
+
) : Nil
|
|
228
|
+
validate_meta_url(part_path, url)
|
|
229
|
+
failures = 0
|
|
230
|
+
|
|
231
|
+
loop do
|
|
232
|
+
offset = File.exists?(part_path) ? File.size(part_path) : 0_i64
|
|
233
|
+
|
|
234
|
+
if offset > total
|
|
235
|
+
remove_part(part_path)
|
|
236
|
+
offset = 0_i64
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
break if offset == total
|
|
240
|
+
|
|
241
|
+
chunk_end = Math.min(offset + chunk_size - 1, total - 1)
|
|
242
|
+
expected = chunk_end - offset + 1
|
|
243
|
+
headers = HTTP::Headers.new
|
|
244
|
+
headers["Accept-Encoding"] = "identity"
|
|
245
|
+
headers["Range"] = "bytes=#{offset}-#{chunk_end}"
|
|
246
|
+
|
|
247
|
+
begin
|
|
248
|
+
File.open(part_path, offset > 0 ? "r+b" : "w+b") do |file|
|
|
249
|
+
file.seek(offset)
|
|
250
|
+
writer = ProgressWriter.new(file, part_path.to_s, offset, cancellation, on_progress)
|
|
251
|
+
writer.total = total
|
|
252
|
+
writer.emit(force: true)
|
|
253
|
+
|
|
254
|
+
@http.stream_to(url, writer, headers: headers) do |status, response_headers, _|
|
|
255
|
+
range = parse_content_range(response_headers["Content-Range"]?)
|
|
256
|
+
unless status.code == 206 &&
|
|
257
|
+
range && range[:start] == offset && range[:end] == chunk_end
|
|
258
|
+
raise ResumeError.new(part_path.to_s, "server did not honor the range request")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
if range_total = range[:total]
|
|
262
|
+
unless range_total == total
|
|
263
|
+
raise ResumeError.new(part_path.to_s, "server reported a different total size")
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
write_meta(part_path, url.to_s, response_headers, total)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
writer.emit(force: true)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
actual = File.size(part_path)
|
|
274
|
+
unless actual == offset + expected
|
|
275
|
+
raise IncompleteDownloadError.new(part_path.to_s, offset + expected, actual)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
failures = 0
|
|
279
|
+
rescue ex : NetworkError | IncompleteResponseError | IncompleteDownloadError
|
|
280
|
+
failures += 1
|
|
281
|
+
raise ex if failures > @options.retries
|
|
282
|
+
sleep(@options.retry_wait(failures))
|
|
283
|
+
rescue ex : HTTPError
|
|
284
|
+
if ex.status.code == 416 && offset > 0
|
|
285
|
+
remove_part(part_path)
|
|
286
|
+
next
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
raise ex unless retryable_status?(ex.status)
|
|
290
|
+
|
|
291
|
+
failures += 1
|
|
292
|
+
raise ex if failures > @options.retries
|
|
293
|
+
sleep(@options.retry_wait(failures))
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
actual = File.exists?(part_path) ? File.size(part_path) : 0_i64
|
|
298
|
+
return if actual == total
|
|
299
|
+
|
|
300
|
+
raise IncompleteDownloadError.new(part_path.to_s, total, actual)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
private def transfer(
|
|
304
|
+
url : Format | String | URI,
|
|
305
|
+
part_path : Path,
|
|
306
|
+
cancellation : Cancellation?,
|
|
307
|
+
on_progress : Proc(DownloadProgress, Nil)?,
|
|
308
|
+
) : Nil
|
|
309
|
+
attempt = 0
|
|
310
|
+
validate_meta_url(part_path, url)
|
|
311
|
+
|
|
312
|
+
loop do
|
|
313
|
+
attempt += 1
|
|
314
|
+
offset = current_offset(part_path)
|
|
315
|
+
meta = read_meta(part_path)
|
|
316
|
+
request_headers = build_request_headers(offset, meta)
|
|
317
|
+
expected_total = nil
|
|
318
|
+
|
|
319
|
+
begin
|
|
320
|
+
File.open(part_path, offset > 0 ? "r+b" : "w+b") do |file|
|
|
321
|
+
file.seek(offset)
|
|
322
|
+
writer = ProgressWriter.new(file, part_path.to_s, offset, cancellation, on_progress)
|
|
323
|
+
writer.emit(force: true)
|
|
324
|
+
|
|
325
|
+
@http.stream_to(url, writer, headers: request_headers, max_bytes: nil) do |status, headers, _|
|
|
326
|
+
resumed_offset = response_offset(status, headers, offset, part_path, file)
|
|
327
|
+
expected_total = expected_total_for(status, headers)
|
|
328
|
+
writer.base_offset = resumed_offset
|
|
329
|
+
writer.total = expected_total
|
|
330
|
+
write_meta(part_path, url.to_s, headers, expected_total)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
writer.emit(force: true)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
verify_size(part_path, expected_total)
|
|
337
|
+
return
|
|
338
|
+
rescue ex : NetworkError | IncompleteResponseError | IncompleteDownloadError
|
|
339
|
+
raise ex if attempt > @options.retries
|
|
340
|
+
sleep(@options.retry_wait(attempt))
|
|
341
|
+
rescue ex : ResumeError
|
|
342
|
+
remove_part(part_path)
|
|
343
|
+
raise ex if attempt > @options.retries
|
|
344
|
+
rescue ex : HTTPError
|
|
345
|
+
if offset > 0 && ex.status.code == 416
|
|
346
|
+
remove_part(part_path)
|
|
347
|
+
raise ex if attempt > @options.retries
|
|
348
|
+
next
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
raise ex unless retryable_status?(ex.status)
|
|
352
|
+
raise ex if attempt > @options.retries
|
|
353
|
+
|
|
354
|
+
sleep(@options.retry_wait(attempt))
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
private def retryable_status?(status : HTTP::Status) : Bool
|
|
360
|
+
{
|
|
361
|
+
408, 425, 429, 500, 502, 503, 504,
|
|
362
|
+
}.includes?(status.code)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
private def transfer_dash(
|
|
366
|
+
url : String | URI,
|
|
367
|
+
part_path : Path,
|
|
368
|
+
reference : String,
|
|
369
|
+
cancellation : Cancellation?,
|
|
370
|
+
on_progress : Proc(DownloadProgress, Nil)?,
|
|
371
|
+
) : Nil
|
|
372
|
+
response = @http.get(url)
|
|
373
|
+
manifest = Dash::Parser.parse(response.body, response.url)
|
|
374
|
+
representation = manifest.representations.find { |item| item.reference == reference }
|
|
375
|
+
|
|
376
|
+
unless representation
|
|
377
|
+
raise UnsupportedManifestError.new("DASH representation #{reference} is no longer present")
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
unless representation.supported?
|
|
381
|
+
raise UnsupportedManifestError.new("DASH representation #{reference} uses unsupported segment information")
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
init_url, segments = representation.expand
|
|
385
|
+
if segments.empty?
|
|
386
|
+
raise ManifestError.new("DASH representation #{reference} has no segments")
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
mime = representation.mime
|
|
390
|
+
unless mime
|
|
391
|
+
raise UnsupportedManifestError.new("DASH representation #{reference} has no media type")
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
SegmentAssembly.for_dash(mime, !init_url.nil?)
|
|
395
|
+
|
|
396
|
+
File.open(part_path, "w+b") do |file|
|
|
397
|
+
writer = ProgressWriter.new(file, part_path.to_s, 0_i64, cancellation, on_progress)
|
|
398
|
+
writer.emit(force: true)
|
|
399
|
+
|
|
400
|
+
if init_url
|
|
401
|
+
@http.stream_to(init_url, writer)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
segments.each do |segment|
|
|
405
|
+
@http.stream_to(segment.url, writer)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
writer.emit(force: true)
|
|
409
|
+
end
|
|
410
|
+
rescue ex : Rill::Error | IO::Error
|
|
411
|
+
remove_part(part_path)
|
|
412
|
+
raise ex
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
private def transfer_hls(
|
|
416
|
+
url : String | URI,
|
|
417
|
+
part_path : Path,
|
|
418
|
+
cancellation : Cancellation?,
|
|
419
|
+
on_progress : Proc(DownloadProgress, Nil)?,
|
|
420
|
+
) : Nil
|
|
421
|
+
response = @http.get(url)
|
|
422
|
+
playlist = Hls::Parser.parse(response.body, response.url)
|
|
423
|
+
|
|
424
|
+
unless playlist.is_a?(Hls::MediaPlaylist)
|
|
425
|
+
raise UnsupportedManifestError.new("nested HLS master playlists are not supported")
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
validate_hls(playlist)
|
|
429
|
+
validate_hls_assembly(playlist)
|
|
430
|
+
|
|
431
|
+
File.open(part_path, "w+b") do |file|
|
|
432
|
+
writer = ProgressWriter.new(file, part_path.to_s, 0_i64, cancellation, on_progress)
|
|
433
|
+
writer.emit(force: true)
|
|
434
|
+
|
|
435
|
+
if map_url = playlist.map_url
|
|
436
|
+
@http.stream_to(map_url, writer)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
playlist.segments.each do |segment|
|
|
440
|
+
@http.stream_to(segment.url, writer)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
writer.emit(force: true)
|
|
444
|
+
end
|
|
445
|
+
rescue ex : Rill::Error | IO::Error
|
|
446
|
+
remove_part(part_path)
|
|
447
|
+
raise ex
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
private def validate_hls_assembly(playlist : Hls::MediaPlaylist) : Nil
|
|
451
|
+
extensions = playlist.segments.map do |segment|
|
|
452
|
+
File.extname(segment.url.path).lchop('.').downcase
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
SegmentAssembly.for_hls(playlist.map_url, extensions)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
private def validate_hls(playlist : Hls::MediaPlaylist) : Nil
|
|
459
|
+
if playlist.encrypted
|
|
460
|
+
raise UnsupportedManifestError.new("encrypted HLS playlists are not supported")
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
unless playlist.ended
|
|
464
|
+
raise UnsupportedManifestError.new("live HLS playlists are not supported")
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
if playlist.map_byte_range
|
|
468
|
+
raise UnsupportedManifestError.new("HLS map byte ranges are not supported")
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
if playlist.segments.any? { |segment| segment.byte_range }
|
|
472
|
+
raise UnsupportedManifestError.new("HLS segment byte ranges are not supported")
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
private def current_offset(part_path : Path) : Int64
|
|
477
|
+
return 0_i64 unless @resume
|
|
478
|
+
return 0_i64 unless File.exists?(part_path)
|
|
479
|
+
|
|
480
|
+
File.size(part_path)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
private def build_request_headers(offset : Int64, meta : Hash(String, String)) : HTTP::Headers
|
|
484
|
+
headers = HTTP::Headers.new
|
|
485
|
+
headers["Accept-Encoding"] = "identity"
|
|
486
|
+
|
|
487
|
+
if offset > 0
|
|
488
|
+
headers["Range"] = "bytes=#{offset}-"
|
|
489
|
+
validator = meta["etag"]? || meta["last_modified"]?
|
|
490
|
+
headers["If-Range"] = validator if validator
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
headers
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
private def response_offset(
|
|
497
|
+
status : HTTP::Status,
|
|
498
|
+
headers : HTTP::Headers,
|
|
499
|
+
requested_offset : Int64,
|
|
500
|
+
part_path : Path,
|
|
501
|
+
file : File,
|
|
502
|
+
) : Int64
|
|
503
|
+
if status.code == 206
|
|
504
|
+
range = parse_content_range(headers["Content-Range"]?)
|
|
505
|
+
unless range && range[:start] == requested_offset
|
|
506
|
+
raise ResumeError.new(part_path.to_s, "server returned an invalid Content-Range")
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
requested_offset
|
|
510
|
+
else
|
|
511
|
+
begin
|
|
512
|
+
file.truncate(0)
|
|
513
|
+
file.seek(0)
|
|
514
|
+
rescue ex : IO::Error
|
|
515
|
+
raise DownloadWriteError.new(part_path.to_s, ex)
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
0_i64
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
private def expected_total_for(status : HTTP::Status, headers : HTTP::Headers) : Int64?
|
|
523
|
+
if status.code == 206
|
|
524
|
+
range = parse_content_range(headers["Content-Range"]?)
|
|
525
|
+
return nil unless range
|
|
526
|
+
return range[:total] if range[:total]
|
|
527
|
+
return range[:end] + 1
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
headers["Content-Length"]?.try(&.to_i64?)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
private def parse_content_range(
|
|
534
|
+
value : String?,
|
|
535
|
+
) : NamedTuple(start: Int64, end: Int64, total: Int64?)?
|
|
536
|
+
return nil unless value
|
|
537
|
+
|
|
538
|
+
match = CONTENT_RANGE.match(value.strip)
|
|
539
|
+
return nil unless match
|
|
540
|
+
|
|
541
|
+
start = match[1].to_i64?
|
|
542
|
+
finish = match[2].to_i64?
|
|
543
|
+
return nil unless start && finish
|
|
544
|
+
return nil if finish < start
|
|
545
|
+
|
|
546
|
+
total = match[3] == "*" ? nil : match[3].to_i64?
|
|
547
|
+
return nil if total && finish >= total
|
|
548
|
+
|
|
549
|
+
{start: start, end: finish, total: total}
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
private def verify_size(part_path : Path, expected_total : Int64?) : Nil
|
|
553
|
+
return unless expected_total
|
|
554
|
+
|
|
555
|
+
actual = File.size(part_path)
|
|
556
|
+
return if actual == expected_total
|
|
557
|
+
|
|
558
|
+
raise IncompleteDownloadError.new(part_path.to_s, expected_total, actual)
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
private def publish(part_path : Path, target_path : Path, overwrite : Bool) : Nil
|
|
562
|
+
if !overwrite && File.exists?(target_path)
|
|
563
|
+
raise FileExistsError.new(target_path.to_s)
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
begin
|
|
567
|
+
File.rename(part_path, target_path)
|
|
568
|
+
rescue ex : File::Error
|
|
569
|
+
unless overwrite && File.exists?(target_path)
|
|
570
|
+
raise FileExistsError.new(target_path.to_s)
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
File.delete(target_path)
|
|
574
|
+
File.rename(part_path, target_path)
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
remove_meta(part_path)
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
private def meta_path(part_path : Path) : Path
|
|
581
|
+
Path.new("#{part_path}#{META_SUFFIX}")
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
private def read_meta(part_path : Path) : Hash(String, String)
|
|
585
|
+
path = meta_path(part_path)
|
|
586
|
+
return {} of String => String unless File.exists?(path)
|
|
587
|
+
|
|
588
|
+
object = JSON.parse(File.read(path)).as_h?
|
|
589
|
+
return {} of String => String unless object
|
|
590
|
+
|
|
591
|
+
result = {} of String => String
|
|
592
|
+
object.each do |key, value|
|
|
593
|
+
text = value.as_s?
|
|
594
|
+
result[key] = text if text
|
|
595
|
+
end
|
|
596
|
+
result
|
|
597
|
+
rescue File::Error | JSON::ParseException
|
|
598
|
+
{} of String => String
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
private def write_meta(
|
|
602
|
+
part_path : Path,
|
|
603
|
+
url : String,
|
|
604
|
+
headers : HTTP::Headers,
|
|
605
|
+
total : Int64?,
|
|
606
|
+
) : Nil
|
|
607
|
+
values = read_meta(part_path)
|
|
608
|
+
values["url"] = url
|
|
609
|
+
if etag = headers["ETag"]?
|
|
610
|
+
values["etag"] = etag
|
|
611
|
+
end
|
|
612
|
+
if modified = headers["Last-Modified"]?
|
|
613
|
+
values["last_modified"] = modified
|
|
614
|
+
end
|
|
615
|
+
if total
|
|
616
|
+
values["total"] = total.to_s
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
return unless values.size > 1
|
|
620
|
+
|
|
621
|
+
JSON.build do |json|
|
|
622
|
+
json.object do
|
|
623
|
+
values.each do |key, value|
|
|
624
|
+
json.field key, value
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
end.tap do |content|
|
|
628
|
+
File.write(meta_path(part_path), content)
|
|
629
|
+
end
|
|
630
|
+
rescue File::Error
|
|
631
|
+
nil
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
private def validate_meta_url(part_path : Path, url : String | URI) : Nil
|
|
635
|
+
return unless @resume
|
|
636
|
+
return unless File.exists?(part_path)
|
|
637
|
+
|
|
638
|
+
meta = read_meta(part_path)
|
|
639
|
+
stored = meta["url"]?
|
|
640
|
+
return unless stored
|
|
641
|
+
return if stored == url.to_s
|
|
642
|
+
|
|
643
|
+
remove_part(part_path)
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
private def remove_part(part_path : Path) : Nil
|
|
647
|
+
File.delete(part_path) if File.exists?(part_path)
|
|
648
|
+
remove_meta(part_path)
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
private def remove_meta(part_path : Path) : Nil
|
|
652
|
+
path = meta_path(part_path)
|
|
653
|
+
File.delete(path) if File.exists?(path)
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
private def ensure_not_symlink(path : Path) : Nil
|
|
657
|
+
return unless File.symlink?(path)
|
|
658
|
+
|
|
659
|
+
raise DownloadError.new("refusing to use symlink #{path}")
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
end
|