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