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