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,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