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.
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,637 @@
1
+ require "json"
2
+ require "option_parser"
3
+ require "path"
4
+ require "signal"
5
+
6
+ require "./rill"
7
+
8
+ module Rill::CLI
9
+ USAGE = <<-TEXT
10
+ Usage: rill <command> [options]
11
+
12
+ Commands:
13
+ extract URL Print media metadata and available formats
14
+ formats URL Print available formats only
15
+ download URL Download a media format to a file
16
+ merge VIDEO AUDIO -o OUTPUT
17
+ Remux separate video and audio files
18
+
19
+ Options:
20
+ -j, --json Print JSON
21
+ -H, --header NAME:VALUE Add a request header
22
+ -c, --cookie NAME=VALUE Add a request cookie
23
+ -t, --timeout SECONDS Set the connect and read timeout
24
+ -u, --user-agent AGENT Set the request user agent
25
+ -h, --help Show this help
26
+ -v, --version Show the version
27
+ TEXT
28
+
29
+ def self.run(argv : Array(String)) : Int32
30
+ args = argv.dup
31
+ command = args.shift?
32
+
33
+ case command
34
+ when nil, "-h", "--help"
35
+ puts USAGE
36
+ 0
37
+ when "-v", "--version"
38
+ puts "rill #{VERSION}"
39
+ 0
40
+ when "extract", "formats"
41
+ run_extraction(command, args)
42
+ when "download"
43
+ run_download(args)
44
+ when "merge"
45
+ run_merge(args)
46
+ else
47
+ STDERR.puts "unknown command: #{command}"
48
+ STDERR.puts USAGE
49
+ 2
50
+ end
51
+ end
52
+
53
+ private def self.run_extraction(command : String, args : Array(String)) : Int32
54
+ options = RequestOptions.new
55
+ json = false
56
+ help = false
57
+
58
+ parser = OptionParser.new
59
+ parser.banner = USAGE
60
+ parser.on("-j", "--json", "Print JSON") { json = true }
61
+ add_request_options(parser, options)
62
+ parser.on("-h", "--help", "Show this help") { help = true }
63
+
64
+ remaining = args.dup
65
+ parser.parse(remaining)
66
+
67
+ if help
68
+ puts parser
69
+ return 0
70
+ end
71
+
72
+ if remaining.size != 1
73
+ STDERR.puts "expected exactly one URL"
74
+ STDERR.puts parser
75
+ return 2
76
+ end
77
+
78
+ result = Client.new(options).extract(remaining.first)
79
+
80
+ if json
81
+ puts command == "formats" ? formats_json(result) : result.to_pretty_json
82
+ else
83
+ case result
84
+ when Playlist
85
+ print_playlist(result)
86
+ when Media
87
+ print_media(result, formats_only: command == "formats")
88
+ end
89
+ end
90
+
91
+ 0
92
+ rescue ex : OptionParser::Exception
93
+ STDERR.puts ex.message
94
+ STDERR.puts USAGE
95
+ 2
96
+ rescue ex : Rill::Error
97
+ STDERR.puts ex.message
98
+ 1
99
+ rescue ex : ArgumentError
100
+ STDERR.puts ex.message
101
+ 1
102
+ end
103
+
104
+ private def self.run_merge(args : Array(String)) : Int32
105
+ output : String? = nil
106
+ force = false
107
+ backend = MediaMuxer.backend_from_environment
108
+ help = false
109
+
110
+ parser = OptionParser.new
111
+ parser.banner = USAGE
112
+ parser.on("-o PATH", "--output PATH", "Write to this file") do |value|
113
+ output = value
114
+ end
115
+ parser.on("--force", "Overwrite an existing file") { force = true }
116
+ parser.on("--media-backend MODE", "auto, helper, system, or off") do |value|
117
+ backend = MediaMuxer.parse_backend(value) || raise OptionParser::InvalidOption.new(value)
118
+ end
119
+ parser.on("-h", "--help", "Show this help") { help = true }
120
+
121
+ remaining = args.dup
122
+ parser.parse(remaining)
123
+
124
+ if help
125
+ puts parser
126
+ return 0
127
+ end
128
+
129
+ if remaining.size != 2
130
+ STDERR.puts "expected a video and an audio file"
131
+ STDERR.puts parser
132
+ return 2
133
+ end
134
+
135
+ unless output
136
+ STDERR.puts "missing --output"
137
+ return 2
138
+ end
139
+
140
+ muxer = MediaMuxer.new(backend)
141
+ path = muxer.merge(remaining[0], remaining[1], output.not_nil!, force)
142
+ puts path
143
+ 0
144
+ rescue ex : OptionParser::Exception
145
+ STDERR.puts ex.message
146
+ STDERR.puts USAGE
147
+ 2
148
+ rescue ex : Rill::Error
149
+ STDERR.puts ex.message
150
+ 1
151
+ rescue ex : ArgumentError
152
+ STDERR.puts ex.message
153
+ 1
154
+ end
155
+
156
+ private def self.add_request_options(parser : OptionParser, options : RequestOptions) : Nil
157
+ parser.on("-H NAME:VALUE", "Add a request header") do |value|
158
+ add_header(options, value)
159
+ end
160
+ parser.on("-c NAME=VALUE", "Add a request cookie") do |value|
161
+ add_cookie(options, value)
162
+ end
163
+ parser.on("-t SECONDS", "Set the connect and read timeout") do |value|
164
+ seconds = value.to_f?
165
+ unless seconds && seconds > 0
166
+ raise OptionParser::InvalidOption.new(value)
167
+ end
168
+
169
+ options.connect_timeout = seconds.seconds
170
+ options.read_timeout = seconds.seconds
171
+ end
172
+ parser.on("-u AGENT", "Set the request user agent") do |value|
173
+ if value.empty?
174
+ raise OptionParser::InvalidOption.new(value)
175
+ end
176
+
177
+ options.user_agent = value
178
+ end
179
+ end
180
+
181
+ private def self.run_download(args : Array(String)) : Int32
182
+ options = RequestOptions.new
183
+ output : String? = nil
184
+ directory = "."
185
+ directory_given = false
186
+ format_id : String? = nil
187
+ audio_only = false
188
+ video_only = false
189
+ force = false
190
+ resume = true
191
+ playlist_mode = false
192
+ media_backend = MediaMuxer.backend_from_environment
193
+ json = false
194
+ help = false
195
+
196
+ parser = OptionParser.new
197
+ parser.banner = USAGE
198
+ parser.on("-o PATH", "--output PATH", "Write to an explicit file path") do |value|
199
+ output = value
200
+ end
201
+ parser.on("-P DIR", "--directory DIR", "Write into this directory") do |value|
202
+ directory = value
203
+ directory_given = true
204
+ end
205
+ parser.on("-f ID", "--format ID", "Use a specific format id") do |value|
206
+ format_id = value
207
+ end
208
+ parser.on("-a", "--audio", "Download the best audio-only format") { audio_only = true }
209
+ parser.on("--video-only", "Download the best video-only format") { video_only = true }
210
+ parser.on("--force", "Overwrite an existing file") { force = true }
211
+ parser.on("--no-resume", "Do not resume an existing part file") { resume = false }
212
+ parser.on("--playlist", "Download every entry in a playlist") { playlist_mode = true }
213
+ parser.on("--media-backend MODE", "auto, helper, system, or off") do |value|
214
+ media_backend = MediaMuxer.parse_backend(value) || raise OptionParser::InvalidOption.new(value)
215
+ end
216
+ parser.on("-j", "--json", "Print JSON") { json = true }
217
+ add_request_options(parser, options)
218
+ parser.on("-h", "--help", "Show this help") { help = true }
219
+
220
+ remaining = args.dup
221
+ parser.parse(remaining)
222
+
223
+ if help
224
+ puts parser
225
+ return 0
226
+ end
227
+
228
+ if remaining.size != 1
229
+ STDERR.puts "expected exactly one URL"
230
+ STDERR.puts parser
231
+ return 2
232
+ end
233
+
234
+ if output && directory_given
235
+ STDERR.puts "--output and --directory cannot be combined"
236
+ return 2
237
+ end
238
+
239
+ if audio_only && video_only
240
+ STDERR.puts "--audio and --video-only cannot be combined"
241
+ return 2
242
+ end
243
+
244
+ if format_id && (audio_only || video_only)
245
+ STDERR.puts "--format cannot be combined with --audio or --video-only"
246
+ return 2
247
+ end
248
+
249
+ preference = if audio_only
250
+ FormatPreference::AudioOnly
251
+ elsif video_only
252
+ FormatPreference::VideoOnly
253
+ else
254
+ FormatPreference::AudioAndVideo
255
+ end
256
+
257
+ client = Client.new(options)
258
+ result = client.extract(remaining.first)
259
+
260
+ if result.is_a?(Playlist)
261
+ unless playlist_mode
262
+ STDERR.puts "URL is a playlist; pass --playlist to download its entries"
263
+ return 1
264
+ end
265
+
266
+ if output
267
+ STDERR.puts "--output cannot be used with a playlist"
268
+ return 2
269
+ end
270
+
271
+ cancellation = Cancellation.new
272
+ Signal::INT.trap { |_signal| cancellation.cancel! }
273
+ Signal::TERM.trap { |_signal| cancellation.cancel! }
274
+
275
+ return download_playlist(
276
+ client,
277
+ result,
278
+ directory,
279
+ format_id,
280
+ resume,
281
+ force,
282
+ media_backend,
283
+ preference,
284
+ cancellation,
285
+ )
286
+ end
287
+
288
+ media = result
289
+ media.warnings.each do |warning|
290
+ STDERR.puts "warning: #{warning}"
291
+ end
292
+
293
+ plan = FormatSelector.plan(media.formats, format_id, preference)
294
+
295
+ unless plan
296
+ STDERR.puts format_plan_error(format_id, preference)
297
+ return 1
298
+ end
299
+
300
+ if preference.audio_and_video? && plan.stream.kind.video? && plan.audio.nil?
301
+ STDERR.puts "no audio track is available for this video"
302
+ end
303
+
304
+ target = if explicit = output
305
+ Path.new(explicit)
306
+ else
307
+ Dir.mkdir_p(directory)
308
+ OutputFileName.resolve(directory, media.title, plan.container)
309
+ end
310
+
311
+ cancellation = Cancellation.new
312
+ Signal::INT.trap { |_signal| cancellation.cancel! }
313
+ Signal::TERM.trap { |_signal| cancellation.cancel! }
314
+
315
+ on_progress = ->(progress : DownloadProgress) { print_progress(progress) }
316
+ path = client.download(
317
+ plan,
318
+ target,
319
+ cancellation,
320
+ on_progress,
321
+ resume: resume,
322
+ overwrite: force,
323
+ media_backend: media_backend,
324
+ )
325
+ clear_progress_line
326
+
327
+ if json
328
+ puts download_json(path, plan)
329
+ else
330
+ puts path
331
+ end
332
+
333
+ 0
334
+ rescue ex : OptionParser::Exception
335
+ STDERR.puts ex.message
336
+ STDERR.puts USAGE
337
+ 2
338
+ rescue ex : CancelledError
339
+ clear_progress_line
340
+ STDERR.puts "cancelled"
341
+ 130
342
+ rescue ex : Rill::Error
343
+ clear_progress_line
344
+ STDERR.puts ex.message
345
+ 1
346
+ rescue ex : File::Error
347
+ clear_progress_line
348
+ STDERR.puts ex.message
349
+ 1
350
+ rescue ex : ArgumentError
351
+ clear_progress_line
352
+ STDERR.puts ex.message
353
+ 1
354
+ end
355
+
356
+ private def self.download_playlist(
357
+ client : Client,
358
+ playlist : Playlist,
359
+ directory : String,
360
+ format_id : String?,
361
+ resume : Bool,
362
+ force : Bool,
363
+ media_backend : MediaMuxer::Backend,
364
+ preference : FormatPreference,
365
+ cancellation : Cancellation,
366
+ ) : Int32
367
+ Dir.mkdir_p(directory)
368
+ failures = 0
369
+ total = playlist.entries.size
370
+
371
+ playlist.entries.each_with_index do |entry, index|
372
+ if message = entry.error
373
+ failures += 1
374
+ STDERR.puts "[#{index + 1}/#{total}] skipped: #{message}"
375
+ next
376
+ end
377
+
378
+ begin
379
+ result = client.extract(entry.url)
380
+ unless result.is_a?(Media)
381
+ raise DownloadError.new("entry is another playlist")
382
+ end
383
+
384
+ result.warnings.each do |warning|
385
+ STDERR.puts " warning: #{warning}"
386
+ end
387
+
388
+ plan = FormatSelector.plan(result.formats, format_id, preference)
389
+ unless plan
390
+ raise DownloadError.new(format_plan_error(format_id, preference))
391
+ end
392
+
393
+ target = OutputFileName.resolve(directory, result.title, plan.container)
394
+ STDERR.puts "[#{index + 1}/#{total}] #{result.title}"
395
+ path = client.download(
396
+ plan,
397
+ target,
398
+ cancellation,
399
+ nil,
400
+ resume: resume,
401
+ overwrite: force,
402
+ media_backend: media_backend,
403
+ )
404
+ puts path
405
+ rescue ex : CancelledError
406
+ raise ex
407
+ rescue ex : Rill::Error
408
+ failures += 1
409
+ STDERR.puts " failed: #{ex.message}"
410
+ rescue ex : ArgumentError
411
+ failures += 1
412
+ STDERR.puts " failed: #{ex.message}"
413
+ end
414
+ end
415
+
416
+ if failures > 0
417
+ STDERR.puts "completed with #{failures} failed #{failures == 1 ? "entry" : "entries"}"
418
+ end
419
+
420
+ failures.zero? ? 0 : 1
421
+ rescue ex : CancelledError
422
+ clear_progress_line
423
+ STDERR.puts "cancelled"
424
+ 130
425
+ end
426
+
427
+ private def self.format_plan_error(
428
+ format_id : String?,
429
+ preference : FormatPreference,
430
+ ) : String
431
+ return "unknown format: #{format_id}" if format_id
432
+
433
+ case preference
434
+ when .audio_only?
435
+ "no audio-only format is available"
436
+ when .video_only?
437
+ "no video-only format is available"
438
+ else
439
+ "no downloadable formats"
440
+ end
441
+ end
442
+
443
+ private def self.print_progress(progress : DownloadProgress) : Nil
444
+ return unless STDERR.tty?
445
+
446
+ STDERR.print "\r\e[K#{progress_text(progress)}"
447
+ STDERR.flush
448
+ end
449
+
450
+ private def self.clear_progress_line : Nil
451
+ return unless STDERR.tty?
452
+
453
+ STDERR.print "\r\e[K"
454
+ STDERR.flush
455
+ end
456
+
457
+ private def self.progress_text(progress : DownloadProgress) : String
458
+ downloaded = human_size(progress.downloaded_bytes).not_nil!
459
+
460
+ if fraction = progress.fraction
461
+ total = human_size(progress.total_bytes).not_nil!
462
+ "#{(fraction * 100).round(1)}% #{downloaded} / #{total}"
463
+ else
464
+ downloaded
465
+ end
466
+ end
467
+
468
+ private def self.download_json(path : Path, plan : DownloadPlan) : String
469
+ JSON.build(indent: 2) do |json|
470
+ json.object do
471
+ json.field "path", path.to_s
472
+ json.field "format_id", plan.stream.format_id
473
+ json.field "url", plan.stream.url.to_s
474
+ json.field "audio_format_id", plan.audio.try(&.format_id)
475
+ end
476
+ end
477
+ end
478
+
479
+ private def self.add_header(options : RequestOptions, value : String) : Nil
480
+ name, separator, header_value = value.partition(':')
481
+ if separator.empty? || name.strip.empty?
482
+ raise OptionParser::InvalidOption.new(value)
483
+ end
484
+
485
+ begin
486
+ options.headers.add(name.strip, header_value.strip)
487
+ rescue ArgumentError
488
+ raise OptionParser::InvalidOption.new(value)
489
+ end
490
+ end
491
+
492
+ private def self.add_cookie(options : RequestOptions, value : String) : Nil
493
+ name, separator, cookie_value = value.partition('=')
494
+ if separator.empty? || name.strip.empty?
495
+ raise OptionParser::InvalidOption.new(value)
496
+ end
497
+ options.cookies[name.strip] = cookie_value.strip
498
+ end
499
+
500
+ private def self.formats_json(result : Media | Playlist) : String
501
+ JSON.build(indent: 2) do |json|
502
+ json.array do
503
+ case result
504
+ when Media
505
+ result.formats.each(&.to_json(json))
506
+ when Playlist
507
+ result.entries.each do |entry|
508
+ json.object do
509
+ json.field "url", entry.url.to_s
510
+ json.field "title", entry.title
511
+ json.field "id", entry.id
512
+ end
513
+ end
514
+ end
515
+ end
516
+ end
517
+ end
518
+
519
+ private def self.print_playlist(playlist : Playlist) : Nil
520
+ puts "Title: #{playlist.title}"
521
+ puts "Extractor: #{playlist.extractor}"
522
+ puts "ID: #{playlist.id}" if playlist.id
523
+ puts "Webpage: #{playlist.webpage_url}"
524
+ puts
525
+ puts "Entries: #{playlist.entries.size}"
526
+
527
+ playlist.entries.each_with_index do |entry, index|
528
+ puts " [#{index + 1}] #{entry.title || entry.url}"
529
+ puts " #{entry.url}"
530
+ puts " error: #{entry.error}" if entry.error
531
+ end
532
+ end
533
+
534
+ private def self.print_media(media : Media, formats_only : Bool) : Nil
535
+ media.warnings.each do |warning|
536
+ STDERR.puts "warning: #{warning}"
537
+ end
538
+
539
+ unless formats_only
540
+ puts "Title: #{media.title}"
541
+ puts "Extractor: #{media.extractor}"
542
+ puts "ID: #{media.id}" if media.id
543
+ puts "Uploader: #{media.uploader}" if media.uploader
544
+ puts "Date: #{media.upload_date}" if media.upload_date
545
+ if duration = human_duration(media.duration_seconds)
546
+ puts "Duration: #{duration}"
547
+ end
548
+ puts "Thumbnail: #{media.thumbnail_url}" if media.thumbnail_url
549
+ puts "Webpage: #{media.webpage_url}"
550
+ puts
551
+ end
552
+
553
+ puts "Formats: #{media.formats.size}"
554
+ media.formats.each_with_index do |format, index|
555
+ puts " [#{index + 1}] #{format.format_id}"
556
+ details = format_details(format)
557
+ puts " #{details}" unless details.empty?
558
+ puts " #{format.url}"
559
+ end
560
+ end
561
+
562
+ private def self.format_details(format : Format) : String
563
+ details = [] of String
564
+ details << format.kind.to_s.downcase
565
+ details << format.protocol.to_s.downcase unless format.protocol.http?
566
+
567
+ if container = format.container
568
+ details << container
569
+ end
570
+
571
+ if width = format.width
572
+ if height = format.height
573
+ details << "#{width}x#{height}"
574
+ end
575
+ end
576
+
577
+ if fps = human_fps(format.fps)
578
+ details << "#{fps} fps"
579
+ end
580
+
581
+ if size = human_size(format.filesize)
582
+ details << size
583
+ end
584
+
585
+ if note = format.note
586
+ details << note
587
+ end
588
+
589
+ details.join(", ")
590
+ end
591
+
592
+ private def self.human_fps(fps : Float64?) : String?
593
+ return nil unless fps
594
+
595
+ if fps == fps.to_i
596
+ fps.to_i.to_s
597
+ else
598
+ fps.round(2).to_s
599
+ end
600
+ end
601
+
602
+ private def self.human_size(bytes : Int64?) : String?
603
+ return nil unless bytes
604
+ return nil if bytes < 0
605
+
606
+ return "#{bytes} B" if bytes < 1024
607
+
608
+ units = {"KiB", "MiB", "GiB", "TiB"}
609
+ size = bytes.to_f / 1024
610
+ unit = 0
611
+ while size >= 1024 && unit < units.size - 1
612
+ size /= 1024
613
+ unit += 1
614
+ end
615
+ "#{size.round(1)} #{units[unit]}"
616
+ end
617
+
618
+ private def self.human_duration(seconds : Float64?) : String?
619
+ return nil unless seconds
620
+ return nil unless seconds.finite? && seconds >= 0
621
+
622
+ total = seconds.to_i
623
+ hours = total // 3600
624
+ minutes = (total % 3600) // 60
625
+ remaining = total % 60
626
+
627
+ if hours > 0
628
+ "#{hours}h #{minutes}m #{remaining}s"
629
+ elsif minutes > 0
630
+ "#{minutes}m #{remaining}s"
631
+ else
632
+ "#{remaining}s"
633
+ end
634
+ end
635
+ end
636
+
637
+ exit Rill::CLI.run(ARGV)