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,279 @@
1
+ require "uri"
2
+
3
+ require "../errors"
4
+ require "../mime_type"
5
+
6
+ module Rill::Dash
7
+ DURATION = /\AP(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?\z/
8
+ TEMPLATE = /\$(\$|RepresentationID|Bandwidth|Number|Time)(?:%0(\d+)d)?\$/
9
+
10
+ struct Segment
11
+ getter url : URI
12
+ getter duration_seconds : Float64?
13
+
14
+ def initialize(@url : URI, @duration_seconds : Float64? = nil)
15
+ end
16
+ end
17
+
18
+ class SegmentList
19
+ getter initialization : URI?
20
+ getter segments : Array(Segment)
21
+
22
+ def initialize(@initialization : URI?, @segments : Array(Segment))
23
+ end
24
+ end
25
+
26
+ class SegmentTemplate
27
+ getter initialization : String?
28
+ getter media : String
29
+ getter timescale : Int64
30
+ getter duration : Int64?
31
+ getter start_number : Int64
32
+ getter presentation_time_offset : Int64
33
+ getter timeline : Array(TimelineEntry)
34
+
35
+ def initialize(
36
+ @media : String,
37
+ @initialization : String? = nil,
38
+ @timescale : Int64 = 1,
39
+ @duration : Int64? = nil,
40
+ @start_number : Int64 = 1,
41
+ @presentation_time_offset : Int64 = 0,
42
+ @timeline : Array(TimelineEntry) = [] of TimelineEntry,
43
+ )
44
+ end
45
+
46
+ def supported?(period_duration : Float64?) : Bool
47
+ return true unless @timeline.empty?
48
+
49
+ duration = @duration
50
+ return false unless duration && duration > 0
51
+
52
+ !period_duration.nil?
53
+ end
54
+
55
+ def expand(
56
+ base_url : URI,
57
+ reference : String,
58
+ bandwidth : Int64?,
59
+ period_duration : Float64?,
60
+ ) : Tuple(URI?, Array(Segment))
61
+ init_url = if template = @initialization
62
+ Dash.resolve_url(
63
+ base_url,
64
+ Dash.expand_template(template, reference, bandwidth, @start_number, @presentation_time_offset),
65
+ )
66
+ end
67
+
68
+ segments = if @timeline.empty?
69
+ expand_fixed(base_url, reference, bandwidth, period_duration)
70
+ else
71
+ expand_timeline(base_url, reference, bandwidth, period_duration)
72
+ end
73
+
74
+ {init_url, segments}
75
+ end
76
+
77
+ private def expand_fixed(
78
+ base_url : URI,
79
+ reference : String,
80
+ bandwidth : Int64?,
81
+ period_duration : Float64?,
82
+ ) : Array(Segment)
83
+ duration = @duration
84
+ total = period_duration
85
+ unless duration && duration > 0 && total
86
+ raise UnsupportedManifestError.new("DASH segment template needs a duration and a period duration")
87
+ end
88
+
89
+ count = ((total * @timescale) / duration).ceil.to_i64
90
+ segments = [] of Segment
91
+
92
+ count.times do |index|
93
+ number = @start_number + index
94
+ time = @presentation_time_offset + index * duration
95
+ value = Dash.expand_template(@media, reference, bandwidth, number, time)
96
+ segments << Segment.new(
97
+ Dash.resolve_url(base_url, value),
98
+ duration.to_f / @timescale,
99
+ )
100
+ end
101
+
102
+ segments
103
+ end
104
+
105
+ private def expand_timeline(
106
+ base_url : URI,
107
+ reference : String,
108
+ bandwidth : Int64?,
109
+ period_duration : Float64?,
110
+ ) : Array(Segment)
111
+ segments = [] of Segment
112
+ number = @start_number
113
+ current_time = @presentation_time_offset
114
+ period_ticks = period_duration.try { |seconds| (seconds * @timescale).ceil.to_i64 }
115
+
116
+ @timeline.each do |entry|
117
+ start_time = entry.time || current_time
118
+ repeats = entry.repeat
119
+
120
+ if repeats < 0
121
+ total = period_ticks
122
+ unless total
123
+ raise UnsupportedManifestError.new("DASH timeline repeats need a period duration")
124
+ end
125
+ repeats = ((total - start_time) / entry.duration).ceil.to_i64 - 1
126
+ repeats = 0 if repeats < 0
127
+ end
128
+
129
+ (repeats + 1).times do |index|
130
+ time = start_time + index * entry.duration
131
+ value = Dash.expand_template(@media, reference, bandwidth, number, time)
132
+ segments << Segment.new(
133
+ Dash.resolve_url(base_url, value),
134
+ entry.duration.to_f / @timescale,
135
+ )
136
+ number += 1
137
+ end
138
+
139
+ current_time = start_time + (repeats + 1) * entry.duration
140
+ end
141
+
142
+ segments
143
+ end
144
+ end
145
+
146
+ struct TimelineEntry
147
+ getter time : Int64?
148
+ getter duration : Int64
149
+ getter repeat : Int64
150
+
151
+ def initialize(@duration : Int64, @time : Int64? = nil, @repeat : Int64 = 0)
152
+ end
153
+ end
154
+
155
+ class Representation
156
+ getter reference : String
157
+ getter id : String?
158
+ getter bandwidth : Int64?
159
+ getter mime : MimeType?
160
+ getter codecs : String?
161
+ getter width : Int32?
162
+ getter height : Int32?
163
+ getter fps : Float64?
164
+ getter language : String?
165
+ getter base_url : URI
166
+ getter duration_seconds : Float64?
167
+ getter template : SegmentTemplate?
168
+ getter list : SegmentList?
169
+ getter segment_base : Bool
170
+
171
+ def initialize(
172
+ @reference : String,
173
+ @base_url : URI,
174
+ @duration_seconds : Float64?,
175
+ @id : String? = nil,
176
+ @bandwidth : Int64? = nil,
177
+ @mime : MimeType? = nil,
178
+ @codecs : String? = nil,
179
+ @width : Int32? = nil,
180
+ @height : Int32? = nil,
181
+ @fps : Float64? = nil,
182
+ @language : String? = nil,
183
+ @template : SegmentTemplate? = nil,
184
+ @list : SegmentList? = nil,
185
+ @segment_base : Bool = false,
186
+ )
187
+ end
188
+
189
+ def supported? : Bool
190
+ return false if @segment_base
191
+ return true if @list
192
+
193
+ @template.try(&.supported?(@duration_seconds)) || false
194
+ end
195
+
196
+ def expand : Tuple(URI?, Array(Segment))
197
+ if list = @list
198
+ {list.initialization, list.segments}
199
+ elsif template = @template
200
+ template.expand(@base_url, @reference, @bandwidth, @duration_seconds)
201
+ else
202
+ raise UnsupportedManifestError.new("DASH representation has no segment information")
203
+ end
204
+ end
205
+ end
206
+
207
+ struct Manifest
208
+ getter representations : Array(Representation)
209
+ getter duration_seconds : Float64?
210
+
211
+ def initialize(@representations : Array(Representation), @duration_seconds : Float64? = nil)
212
+ end
213
+ end
214
+
215
+ def self.parse_duration(value : String?) : Float64?
216
+ return nil unless value
217
+
218
+ match = DURATION.match(value.strip)
219
+ return nil unless match
220
+
221
+ days = match[1]?.try(&.to_f) || 0.0
222
+ hours = match[2]?.try(&.to_f) || 0.0
223
+ minutes = match[3]?.try(&.to_f) || 0.0
224
+ seconds = match[4]?.try(&.to_f) || 0.0
225
+
226
+ days * 86400 + hours * 3600 + minutes * 60 + seconds
227
+ end
228
+
229
+ def self.expand_template(
230
+ template : String,
231
+ reference : String,
232
+ bandwidth : Int64?,
233
+ number : Int64,
234
+ time : Int64,
235
+ ) : String
236
+ String.build do |io|
237
+ index = 0
238
+
239
+ while match = TEMPLATE.match(template, index)
240
+ io << template[index, match.begin(0) - index]
241
+ value = case match[1]
242
+ when "$"
243
+ "$"
244
+ when "RepresentationID"
245
+ reference
246
+ when "Bandwidth"
247
+ (bandwidth || 0).to_s
248
+ when "Number"
249
+ number.to_s
250
+ when "Time"
251
+ time.to_s
252
+ else
253
+ ""
254
+ end
255
+
256
+ if width = match[2]?.try(&.to_i?)
257
+ value = value.rjust(width, '0')
258
+ end
259
+
260
+ io << value
261
+ index = match.end(0)
262
+ end
263
+
264
+ io << template[index, template.size - index]
265
+ end
266
+ end
267
+
268
+ def self.resolve_url(base : URI, value : String) : URI
269
+ uri = base.resolve(value)
270
+ unless uri.scheme == "http" || uri.scheme == "https"
271
+ raise UnsupportedManifestError.new("DASH segment URL is not HTTP")
272
+ end
273
+ return uri if uri.host
274
+
275
+ raise UnsupportedManifestError.new("DASH segment URL has no host")
276
+ rescue URI::Error
277
+ raise ManifestError.new("invalid DASH segment URL: #{value}")
278
+ end
279
+ end
@@ -0,0 +1,227 @@
1
+ require "set"
2
+ require "uri"
3
+ require "xml"
4
+
5
+ require "../errors"
6
+ require "../mime_type"
7
+ require "./manifest"
8
+
9
+ module Rill::Dash::Parser
10
+ extend self
11
+
12
+ def parse(source : String, base_url : URI) : Manifest
13
+ document = begin
14
+ XML.parse(source)
15
+ rescue XML::Error
16
+ raise ManifestError.new("DASH manifest is not valid XML")
17
+ end
18
+
19
+ root = document.root
20
+ unless root && root.name == "MPD"
21
+ raise ManifestError.new("DASH manifest has no MPD root")
22
+ end
23
+
24
+ if root["type"]? == "dynamic"
25
+ raise UnsupportedManifestError.new("live DASH manifests are not supported")
26
+ end
27
+
28
+ duration = Dash.parse_duration(root["mediaPresentationDuration"]?)
29
+ manifest_base = resolve_base(root, base_url)
30
+ periods = element_children(root, "Period")
31
+
32
+ if periods.empty?
33
+ raise ManifestError.new("DASH manifest has no periods")
34
+ end
35
+
36
+ if periods.size > 1
37
+ raise UnsupportedManifestError.new("multi-period DASH manifests are not supported")
38
+ end
39
+
40
+ period = periods.first
41
+ period_start = Dash.parse_duration(period["start"]?) || 0.0
42
+ period_duration = Dash.parse_duration(period["duration"]?)
43
+ period_duration ||= duration.try { |value| value - period_start }
44
+ period_base = resolve_base(period, manifest_base)
45
+
46
+ representations = [] of Representation
47
+ references = Set(String).new
48
+
49
+ element_children(period, "AdaptationSet").each_with_index do |adaptation, adaptation_index|
50
+ next if element_children(adaptation, "ContentProtection").any?
51
+
52
+ adaptation_base = resolve_base(adaptation, period_base)
53
+ adaptation_template = parse_template(adaptation, adaptation_base)
54
+ adaptation_list = parse_list(adaptation, adaptation_base)
55
+ adaptation_segment_base = element_children(adaptation, "SegmentBase").any?
56
+
57
+ element_children(adaptation, "Representation").each_with_index do |node, representation_index|
58
+ next if element_children(node, "ContentProtection").any?
59
+
60
+ id = attribute(node, "id")
61
+ reference = unique_reference(id, adaptation_index, representation_index, references)
62
+
63
+ representation_base = resolve_base(node, adaptation_base)
64
+ template = parse_template(node, representation_base) || adaptation_template
65
+ list = parse_list(node, representation_base) || adaptation_list
66
+ segment_base = adaptation_segment_base || element_children(node, "SegmentBase").any?
67
+
68
+ mime_value = attribute(node, "mimeType") ||
69
+ attribute(adaptation, "mimeType") ||
70
+ mime_for_content_type(adaptation)
71
+ mime = mime_value.try { |value| MimeType.parse(value) }
72
+ next if mime.nil? || mime.empty?
73
+
74
+ representations << Representation.new(
75
+ reference: reference,
76
+ base_url: representation_base,
77
+ duration_seconds: period_duration,
78
+ id: id,
79
+ bandwidth: attribute(node, "bandwidth").try(&.to_i64?),
80
+ mime: mime,
81
+ codecs: attribute(node, "codecs") || attribute(adaptation, "codecs"),
82
+ width: dimension(attribute(node, "width") || attribute(adaptation, "width")),
83
+ height: dimension(attribute(node, "height") || attribute(adaptation, "height")),
84
+ fps: frame_rate(attribute(node, "frameRate") || attribute(adaptation, "frameRate")),
85
+ language: attribute(node, "lang") || attribute(adaptation, "lang"),
86
+ template: template,
87
+ list: list,
88
+ segment_base: segment_base,
89
+ )
90
+ end
91
+ end
92
+
93
+ Manifest.new(representations, period_duration)
94
+ end
95
+
96
+ private def unique_reference(
97
+ id : String?,
98
+ adaptation_index : Int32,
99
+ representation_index : Int32,
100
+ references : Set(String),
101
+ ) : String
102
+ generated = "rep-#{adaptation_index}-#{representation_index}"
103
+ reference = if id.nil? || id.empty?
104
+ generated
105
+ elsif references.includes?(id)
106
+ "#{id}-#{adaptation_index}-#{representation_index}"
107
+ else
108
+ id
109
+ end
110
+ references << reference
111
+ reference
112
+ end
113
+
114
+ private def resolve_base(node : XML::Node, current : URI) : URI
115
+ value = element_children(node, "BaseURL").first?.try(&.content.strip)
116
+ return current if value.nil? || value.empty?
117
+
118
+ Dash.resolve_url(current, value)
119
+ end
120
+
121
+ private def parse_template(node : XML::Node, base_url : URI) : SegmentTemplate?
122
+ template_node = element_children(node, "SegmentTemplate").first?
123
+ return nil unless template_node
124
+
125
+ media = attribute(template_node, "media")
126
+ return nil unless media
127
+
128
+ timeline = [] of TimelineEntry
129
+ if timeline_node = element_children(template_node, "SegmentTimeline").first?
130
+ element_children(timeline_node, "S").each do |entry|
131
+ duration = attribute(entry, "d").try(&.to_i64?)
132
+ unless duration && duration > 0
133
+ raise ManifestError.new("DASH timeline entry has no duration")
134
+ end
135
+
136
+ timeline << TimelineEntry.new(
137
+ duration,
138
+ attribute(entry, "t").try(&.to_i64?),
139
+ attribute(entry, "r").try(&.to_i64?) || 0_i64,
140
+ )
141
+ end
142
+ end
143
+
144
+ SegmentTemplate.new(
145
+ media: media,
146
+ initialization: attribute(template_node, "initialization"),
147
+ timescale: attribute(template_node, "timescale").try(&.to_i64?) || 1_i64,
148
+ duration: attribute(template_node, "duration").try(&.to_i64?),
149
+ start_number: attribute(template_node, "startNumber").try(&.to_i64?) || 1_i64,
150
+ presentation_time_offset: attribute(template_node, "presentationTimeOffset").try(&.to_i64?) || 0_i64,
151
+ timeline: timeline,
152
+ )
153
+ end
154
+
155
+ private def parse_list(node : XML::Node, base_url : URI) : SegmentList?
156
+ list_node = element_children(node, "SegmentList").first?
157
+ return nil unless list_node
158
+
159
+ initialization = element_children(list_node, "Initialization").first?
160
+ init_url = if initialization
161
+ if initialization["range"]?
162
+ raise UnsupportedManifestError.new("DASH byte-range initialization is not supported")
163
+ end
164
+ source = attribute(initialization, "sourceURL")
165
+ source ? Dash.resolve_url(base_url, source) : nil
166
+ end
167
+
168
+ segments = [] of Segment
169
+ element_children(list_node, "SegmentURL").each do |segment_node|
170
+ if segment_node["mediaRange"]?
171
+ raise UnsupportedManifestError.new("DASH byte-range segments are not supported")
172
+ end
173
+
174
+ media = attribute(segment_node, "media")
175
+ next unless media
176
+
177
+ segments << Segment.new(Dash.resolve_url(base_url, media))
178
+ end
179
+
180
+ SegmentList.new(init_url, segments)
181
+ end
182
+
183
+ private def mime_for_content_type(adaptation : XML::Node) : String?
184
+ case attribute(adaptation, "contentType")
185
+ when "audio"
186
+ "audio/mp4"
187
+ when "video"
188
+ "video/mp4"
189
+ else
190
+ nil
191
+ end
192
+ end
193
+
194
+ private def element_children(node : XML::Node, name : String) : Array(XML::Node)
195
+ node.children.select { |child| child.element? && child.name == name }
196
+ end
197
+
198
+ private def attribute(node : XML::Node, name : String) : String?
199
+ value = node[name]?.try(&.strip)
200
+ return nil if value.nil? || value.empty?
201
+
202
+ value
203
+ end
204
+
205
+ private def dimension(value : String?) : Int32?
206
+ parsed = value.try(&.to_i64?)
207
+ return nil unless parsed && 0 < parsed <= 100_000
208
+
209
+ parsed.to_i32
210
+ end
211
+
212
+ private def frame_rate(value : String?) : Float64?
213
+ return nil unless value
214
+
215
+ if value.includes?('/')
216
+ numerator, denominator = value.split('/', 2).map(&.to_f?)
217
+ return nil unless numerator && denominator && denominator > 0
218
+
219
+ rate = numerator / denominator
220
+ else
221
+ rate = value.to_f?
222
+ end
223
+ return nil unless rate && rate.finite? && rate > 0
224
+
225
+ rate
226
+ end
227
+ end
@@ -0,0 +1,96 @@
1
+ require "json"
2
+
3
+ require "./errors"
4
+ require "./format_selector"
5
+ require "./media_muxer"
6
+ require "./request_options"
7
+
8
+ module Rill
9
+ class DownloadOptions
10
+ getter request : RequestOptions
11
+ getter format_id : String?
12
+ getter preference : FormatPreference
13
+ getter overwrite : Bool
14
+ getter resume : Bool
15
+ getter media_backend : MediaMuxer::Backend
16
+
17
+ def initialize(
18
+ @request : RequestOptions = RequestOptions.new,
19
+ @format_id : String? = nil,
20
+ @preference : FormatPreference = FormatPreference::AudioAndVideo,
21
+ @overwrite : Bool = false,
22
+ @resume : Bool = true,
23
+ @media_backend : MediaMuxer::Backend = MediaMuxer.backend_from_environment,
24
+ )
25
+ end
26
+
27
+ def self.from_json(source : String?) : DownloadOptions
28
+ if source.nil? || source.empty?
29
+ return new
30
+ end
31
+
32
+ object = JSON.parse(source).as_h?
33
+ unless object
34
+ raise InvalidOptionsError.new("download options must be a JSON object")
35
+ end
36
+
37
+ request = RequestOptions.new
38
+ format_id = nil
39
+ audio_only = false
40
+ video_only = false
41
+ overwrite = false
42
+ resume = true
43
+ media_backend = MediaMuxer.backend_from_environment
44
+
45
+ object.each do |key, value|
46
+ case key
47
+ when "request"
48
+ request = RequestOptions.from_json(value.to_json)
49
+ when "format_id"
50
+ format_id = value.as_s? || raise InvalidOptionsError.new("format_id must be a string")
51
+ when "audio_only"
52
+ audio_only = boolean_option(key, value)
53
+ when "video_only"
54
+ video_only = boolean_option(key, value)
55
+ when "overwrite"
56
+ overwrite = boolean_option(key, value)
57
+ when "resume"
58
+ resume = boolean_option(key, value)
59
+ when "media_backend"
60
+ name = value.as_s? || raise InvalidOptionsError.new("media_backend must be a string")
61
+ media_backend = MediaMuxer.parse_backend(name) ||
62
+ raise InvalidOptionsError.new("unknown media_backend #{name}")
63
+ else
64
+ raise InvalidOptionsError.new("unknown download option #{key}")
65
+ end
66
+ end
67
+
68
+ if audio_only && video_only
69
+ raise InvalidOptionsError.new("audio_only and video_only cannot be combined")
70
+ end
71
+
72
+ if format_id && (audio_only || video_only)
73
+ raise InvalidOptionsError.new("format_id cannot be combined with audio_only or video_only")
74
+ end
75
+
76
+ preference = if audio_only
77
+ FormatPreference::AudioOnly
78
+ elsif video_only
79
+ FormatPreference::VideoOnly
80
+ else
81
+ FormatPreference::AudioAndVideo
82
+ end
83
+
84
+ new(request, format_id, preference, overwrite, resume, media_backend)
85
+ rescue JSON::ParseException
86
+ raise InvalidOptionsError.new("download options are not valid JSON")
87
+ end
88
+
89
+ private def self.boolean_option(key : String, value : JSON::Any) : Bool
90
+ raw = value.raw
91
+ return raw if raw.is_a?(Bool)
92
+
93
+ raise InvalidOptionsError.new("#{key} must be a boolean")
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,27 @@
1
+ require "./format"
2
+
3
+ module Rill
4
+ struct DownloadPlan
5
+ getter stream : Format
6
+ getter audio : Format?
7
+
8
+ def initialize(@stream : Format, @audio : Format? = nil)
9
+ end
10
+
11
+ def formats : Array(Format)
12
+ if audio = @audio
13
+ [@stream, audio]
14
+ else
15
+ [@stream]
16
+ end
17
+ end
18
+
19
+ def container : String?
20
+ @stream.container || @audio.try(&.container)
21
+ end
22
+
23
+ def requires_merge? : Bool
24
+ !@audio.nil?
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,16 @@
1
+ module Rill
2
+ struct DownloadProgress
3
+ getter downloaded_bytes : Int64
4
+ getter total_bytes : Int64?
5
+
6
+ def initialize(@downloaded_bytes : Int64, @total_bytes : Int64? = nil)
7
+ end
8
+
9
+ def fraction : Float64?
10
+ total = @total_bytes
11
+ return nil unless total && total > 0
12
+
13
+ (downloaded_bytes.to_f / total).clamp(0.0, 1.0)
14
+ end
15
+ end
16
+ end