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.
- 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,441 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "set"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "xml"
|
|
5
|
+
|
|
6
|
+
require "../errors"
|
|
7
|
+
require "../extractor"
|
|
8
|
+
require "../format"
|
|
9
|
+
require "../http_url"
|
|
10
|
+
require "../json_value"
|
|
11
|
+
require "../media"
|
|
12
|
+
require "../mime_type"
|
|
13
|
+
require "../timestamp"
|
|
14
|
+
require "./direct_media"
|
|
15
|
+
|
|
16
|
+
module Rill::Extractors
|
|
17
|
+
class Generic < Extractor
|
|
18
|
+
def name : String
|
|
19
|
+
"generic"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def matches?(url : URI) : Bool
|
|
23
|
+
url.scheme == "http" || url.scheme == "https"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def extract(url : URI, client : HTTPClient) : Media
|
|
27
|
+
response = client.get(url)
|
|
28
|
+
content_type = response.headers["Content-Type"]?.try(&.split(';').first.strip) || ""
|
|
29
|
+
|
|
30
|
+
mime = MimeType.parse(content_type)
|
|
31
|
+
if mime.video? || mime.audio?
|
|
32
|
+
return direct_media(url, response.url, mime, response.body.bytesize)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
metadata = page_metadata(response.body, response.url)
|
|
36
|
+
formats = metadata.formats
|
|
37
|
+
if formats.empty?
|
|
38
|
+
raise MediaUnavailableError.new(url.to_s, "no direct media links were found")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Media.new(
|
|
42
|
+
title: metadata.title || fallback_title(url),
|
|
43
|
+
description: metadata.description,
|
|
44
|
+
webpage_url: url,
|
|
45
|
+
uploader: metadata.uploader || metadata.site_name,
|
|
46
|
+
duration_seconds: metadata.duration_seconds,
|
|
47
|
+
thumbnail_url: metadata.thumbnail_url,
|
|
48
|
+
upload_date: metadata.upload_date,
|
|
49
|
+
extractor: name,
|
|
50
|
+
formats: formats,
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private def direct_media(
|
|
55
|
+
original_url : URI,
|
|
56
|
+
response_url : URI,
|
|
57
|
+
mime : MimeType,
|
|
58
|
+
size : Int64,
|
|
59
|
+
) : Media
|
|
60
|
+
format = Format.new(
|
|
61
|
+
format_id: "direct",
|
|
62
|
+
url: response_url,
|
|
63
|
+
kind: mime.audio? ? FormatKind::Audio : FormatKind::Combined,
|
|
64
|
+
container: mime.container,
|
|
65
|
+
filesize: size,
|
|
66
|
+
note: "direct response",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
Media.new(
|
|
70
|
+
title: fallback_title(original_url),
|
|
71
|
+
webpage_url: original_url,
|
|
72
|
+
extractor: name,
|
|
73
|
+
formats: [format],
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private def page_metadata(body : String, base_url : URI) : PageMetadata
|
|
78
|
+
document = XML.parse_html(body)
|
|
79
|
+
metadata = PageMetadata.new(base_url)
|
|
80
|
+
first_title = nil.as(String?)
|
|
81
|
+
|
|
82
|
+
each_element(document) do |node|
|
|
83
|
+
case node.name.downcase
|
|
84
|
+
when "title"
|
|
85
|
+
first_title ||= node.content.strip
|
|
86
|
+
when "meta"
|
|
87
|
+
metadata.add_meta(node)
|
|
88
|
+
when "video"
|
|
89
|
+
metadata.add_video(node)
|
|
90
|
+
when "audio"
|
|
91
|
+
metadata.add_audio(node)
|
|
92
|
+
when "source"
|
|
93
|
+
metadata.add_source(node)
|
|
94
|
+
when "script"
|
|
95
|
+
if node["type"]?.try(&.downcase) == "application/ld+json"
|
|
96
|
+
metadata.add_structured_data(node.content)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
metadata.finish(first_title)
|
|
102
|
+
metadata
|
|
103
|
+
rescue XML::Error
|
|
104
|
+
raise MediaUnavailableError.new(base_url.to_s, "page is not valid HTML")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private def each_element(root : XML::Node, & : XML::Node ->) : Nil
|
|
108
|
+
pending = root.children.select(&.element?).to_a
|
|
109
|
+
index = 0
|
|
110
|
+
|
|
111
|
+
while index < pending.size
|
|
112
|
+
node = pending[index]
|
|
113
|
+
index += 1
|
|
114
|
+
|
|
115
|
+
yield node
|
|
116
|
+
|
|
117
|
+
node.children.each do |child|
|
|
118
|
+
pending << child if child.element?
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private def fallback_title(url : URI) : String
|
|
124
|
+
basename = File.basename(url.path)
|
|
125
|
+
stem = File.basename(basename, File.extname(basename))
|
|
126
|
+
decoded = URI.decode(stem)
|
|
127
|
+
decoded.empty? ? url.host.to_s : decoded
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private class PageMetadata
|
|
131
|
+
private struct MediaLink
|
|
132
|
+
getter uri : URI
|
|
133
|
+
getter kind : FormatKind
|
|
134
|
+
getter note : String
|
|
135
|
+
getter extension : String
|
|
136
|
+
getter declared_type : String?
|
|
137
|
+
|
|
138
|
+
def initialize(
|
|
139
|
+
@uri : URI,
|
|
140
|
+
@kind : FormatKind,
|
|
141
|
+
@note : String,
|
|
142
|
+
@extension : String,
|
|
143
|
+
@declared_type : String?,
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
STRUCTURED_TYPES = %w(
|
|
149
|
+
VideoObject AudioObject MediaObject Movie Clip TVEpisode Episode PodcastEpisode
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
getter title : String?
|
|
153
|
+
getter description : String?
|
|
154
|
+
getter site_name : String?
|
|
155
|
+
getter uploader : String?
|
|
156
|
+
getter upload_date : String?
|
|
157
|
+
getter thumbnail_url : URI?
|
|
158
|
+
getter duration_seconds : Float64?
|
|
159
|
+
getter formats = [] of Format
|
|
160
|
+
|
|
161
|
+
@width : Int32?
|
|
162
|
+
@height : Int32?
|
|
163
|
+
@thumbnail_candidate : String?
|
|
164
|
+
@video_type : String?
|
|
165
|
+
@audio_type : String?
|
|
166
|
+
@media = [] of MediaLink
|
|
167
|
+
@seen = Set(String).new
|
|
168
|
+
@structured_data = [] of String
|
|
169
|
+
|
|
170
|
+
def initialize(@base_url : URI)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def add_meta(node : XML::Node) : Nil
|
|
174
|
+
key = node["property"]? || node["name"]?
|
|
175
|
+
content = node["content"]?
|
|
176
|
+
return unless key && content
|
|
177
|
+
|
|
178
|
+
case key.downcase
|
|
179
|
+
when "og:title", "twitter:title"
|
|
180
|
+
@title ||= content
|
|
181
|
+
when "og:description", "description", "twitter:description"
|
|
182
|
+
@description ||= content
|
|
183
|
+
when "og:site_name"
|
|
184
|
+
@site_name ||= content
|
|
185
|
+
when "og:image", "twitter:image"
|
|
186
|
+
@thumbnail_candidate ||= content
|
|
187
|
+
when "og:video:width"
|
|
188
|
+
@width ||= positive_int(content)
|
|
189
|
+
when "og:video:height"
|
|
190
|
+
@height ||= positive_int(content)
|
|
191
|
+
when "og:video:duration"
|
|
192
|
+
@duration_seconds ||= positive_float(content)
|
|
193
|
+
when "og:video:type"
|
|
194
|
+
@video_type ||= content
|
|
195
|
+
when "og:audio:type"
|
|
196
|
+
@audio_type ||= content
|
|
197
|
+
when "og:video", "og:video:url", "og:video:secure_url"
|
|
198
|
+
add_media(content, FormatKind::Combined, "og:video")
|
|
199
|
+
when "og:audio", "og:audio:url", "og:audio:secure_url"
|
|
200
|
+
add_media(content, FormatKind::Audio, "og:audio")
|
|
201
|
+
when "twitter:player:stream"
|
|
202
|
+
add_media(content, FormatKind::Combined, "twitter:stream")
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def add_video(node : XML::Node) : Nil
|
|
207
|
+
if poster = node["poster"]?
|
|
208
|
+
@thumbnail_candidate ||= poster
|
|
209
|
+
end
|
|
210
|
+
if source = node["src"]?
|
|
211
|
+
add_media(source, FormatKind::Combined, "video")
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def add_audio(node : XML::Node) : Nil
|
|
216
|
+
if source = node["src"]?
|
|
217
|
+
add_media(source, FormatKind::Audio, "audio")
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def add_source(node : XML::Node) : Nil
|
|
222
|
+
source = node["src"]?
|
|
223
|
+
return unless source
|
|
224
|
+
|
|
225
|
+
type = node["type"]?
|
|
226
|
+
kind = if type.try(&.downcase.starts_with?("audio/"))
|
|
227
|
+
FormatKind::Audio
|
|
228
|
+
else
|
|
229
|
+
FormatKind::Combined
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
add_media(source, kind, "source", type)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def add_structured_data(text : String) : Nil
|
|
236
|
+
@structured_data << text
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def finish(fallback_title : String?) : Nil
|
|
240
|
+
@structured_data.each { |text| apply_structured_data(text) }
|
|
241
|
+
@title ||= fallback_title
|
|
242
|
+
|
|
243
|
+
if url = @thumbnail_candidate
|
|
244
|
+
@thumbnail_url = HttpUrl.resolve(@base_url, url)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
@media.each do |entry|
|
|
248
|
+
resolved = resolve_format(entry)
|
|
249
|
+
next unless resolved
|
|
250
|
+
|
|
251
|
+
extension, kind = resolved
|
|
252
|
+
audio = kind == FormatKind::Audio
|
|
253
|
+
@formats << Format.new(
|
|
254
|
+
format_id: "media-#{@formats.size + 1}",
|
|
255
|
+
url: entry.uri,
|
|
256
|
+
kind: kind,
|
|
257
|
+
container: extension,
|
|
258
|
+
width: audio ? nil : @width,
|
|
259
|
+
height: audio ? nil : @height,
|
|
260
|
+
duration_seconds: @duration_seconds,
|
|
261
|
+
note: entry.note,
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
private def resolve_format(entry : MediaLink) : Tuple(String, FormatKind)?
|
|
267
|
+
extension = entry.extension
|
|
268
|
+
if DirectMedia::VIDEO_EXTENSIONS.includes?(extension)
|
|
269
|
+
return {extension, entry.kind}
|
|
270
|
+
end
|
|
271
|
+
if DirectMedia::AUDIO_EXTENSIONS.includes?(extension)
|
|
272
|
+
return {extension, FormatKind::Audio}
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
declared = entry.declared_type
|
|
276
|
+
declared ||= entry.kind == FormatKind::Audio ? @audio_type : @video_type
|
|
277
|
+
mime = MimeType.parse(declared || "")
|
|
278
|
+
return nil unless mime.video? || mime.audio?
|
|
279
|
+
|
|
280
|
+
container = mime.container
|
|
281
|
+
return nil unless container
|
|
282
|
+
|
|
283
|
+
kind = mime.audio? ? FormatKind::Audio : entry.kind
|
|
284
|
+
{container, kind}
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
private def apply_structured_data(text : String) : Nil
|
|
288
|
+
value = JSON.parse(text)
|
|
289
|
+
structured_objects(value).each { |object| apply_structured_object(object) }
|
|
290
|
+
rescue JSON::ParseException
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
private def structured_objects(value : JSON::Any) : Array(Hash(String, JSON::Any))
|
|
294
|
+
objects = [] of Hash(String, JSON::Any)
|
|
295
|
+
pending = [value]
|
|
296
|
+
|
|
297
|
+
until pending.empty?
|
|
298
|
+
current = pending.pop
|
|
299
|
+
if array = current.as_a?
|
|
300
|
+
pending.concat(array)
|
|
301
|
+
elsif object = current.as_h?
|
|
302
|
+
objects << object
|
|
303
|
+
if graph = object["@graph"]?
|
|
304
|
+
pending << graph
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
objects
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
private def apply_structured_object(object : Hash(String, JSON::Any)) : Nil
|
|
313
|
+
return unless structured_type?(object["@type"]?) || object["contentUrl"]?
|
|
314
|
+
|
|
315
|
+
@title ||= JsonValue.string(object["name"]?)
|
|
316
|
+
@description ||= JsonValue.string(object["description"]?)
|
|
317
|
+
@uploader ||= structured_author(object["author"]?)
|
|
318
|
+
@upload_date ||= date_prefix(JsonValue.string(object["uploadDate"]?))
|
|
319
|
+
@duration_seconds ||= structured_duration(object["duration"]?)
|
|
320
|
+
@width ||= structured_dimension(object["width"]?)
|
|
321
|
+
@height ||= structured_dimension(object["height"]?)
|
|
322
|
+
@thumbnail_candidate ||= structured_thumbnail(
|
|
323
|
+
object["thumbnailUrl"]? || object["image"]?,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
content_url = JsonValue.string(object["contentUrl"]?)
|
|
327
|
+
return unless content_url
|
|
328
|
+
|
|
329
|
+
declared_type = JsonValue.string(object["encodingFormat"]?)
|
|
330
|
+
declared_type = nil unless declared_type.try(&.includes?('/'))
|
|
331
|
+
kind = MimeType.parse(declared_type || "").audio? ? FormatKind::Audio : FormatKind::Combined
|
|
332
|
+
add_media(content_url, kind, "json-ld", declared_type)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
private def structured_type?(value : JSON::Any?) : Bool
|
|
336
|
+
if text = value.try(&.as_s?)
|
|
337
|
+
return STRUCTURED_TYPES.includes?(text)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
array = value.try(&.as_a?)
|
|
341
|
+
return false unless array
|
|
342
|
+
|
|
343
|
+
array.any? do |item|
|
|
344
|
+
text = item.as_s?
|
|
345
|
+
text ? STRUCTURED_TYPES.includes?(text) : false
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
private def structured_author(value : JSON::Any?) : String?
|
|
350
|
+
return nil unless value
|
|
351
|
+
|
|
352
|
+
if array = value.as_a?
|
|
353
|
+
array.each do |item|
|
|
354
|
+
name = structured_author(item)
|
|
355
|
+
return name if name
|
|
356
|
+
end
|
|
357
|
+
return nil
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
if object = value.as_h?
|
|
361
|
+
JsonValue.string(object["name"]?)
|
|
362
|
+
else
|
|
363
|
+
JsonValue.string(value)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
private def structured_duration(value : JSON::Any?) : Float64?
|
|
368
|
+
return nil unless value
|
|
369
|
+
|
|
370
|
+
if number = JsonValue.number(value)
|
|
371
|
+
number if number >= 0
|
|
372
|
+
else
|
|
373
|
+
Timestamp.iso8601_seconds(JsonValue.string(value))
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
private def structured_dimension(value : JSON::Any?) : Int32?
|
|
378
|
+
if object = value.try(&.as_h?)
|
|
379
|
+
JsonValue.dimension(object["value"]?)
|
|
380
|
+
else
|
|
381
|
+
JsonValue.dimension(value)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
private def structured_thumbnail(value : JSON::Any?) : String?
|
|
386
|
+
return nil unless value
|
|
387
|
+
|
|
388
|
+
if array = value.as_a?
|
|
389
|
+
array.each do |item|
|
|
390
|
+
url = structured_thumbnail(item)
|
|
391
|
+
return url if url
|
|
392
|
+
end
|
|
393
|
+
return nil
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
if object = value.as_h?
|
|
397
|
+
JsonValue.string(object["url"]?)
|
|
398
|
+
else
|
|
399
|
+
JsonValue.string(value)
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
private def date_prefix(value : String?) : String?
|
|
404
|
+
return nil unless value
|
|
405
|
+
|
|
406
|
+
value.size >= 10 ? value[0, 10] : value
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
private def positive_int(value : String) : Int32?
|
|
410
|
+
parsed = value.to_i?
|
|
411
|
+
parsed if parsed && parsed > 0
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
private def positive_float(value : String) : Float64?
|
|
415
|
+
parsed = value.to_f?
|
|
416
|
+
parsed if parsed && parsed.finite? && parsed > 0
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
private def add_media(
|
|
420
|
+
value : String,
|
|
421
|
+
kind : FormatKind,
|
|
422
|
+
note : String,
|
|
423
|
+
declared_type : String? = nil,
|
|
424
|
+
) : Nil
|
|
425
|
+
uri = HttpUrl.resolve(@base_url, value)
|
|
426
|
+
return unless uri
|
|
427
|
+
return if @seen.includes?(uri.to_s)
|
|
428
|
+
|
|
429
|
+
extension = File.extname(uri.path).lchop('.').downcase
|
|
430
|
+
@seen << uri.to_s
|
|
431
|
+
@media << MediaLink.new(
|
|
432
|
+
uri: uri,
|
|
433
|
+
kind: kind,
|
|
434
|
+
note: note,
|
|
435
|
+
extension: extension,
|
|
436
|
+
declared_type: declared_type,
|
|
437
|
+
)
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
require "../errors"
|
|
4
|
+
require "../extractor"
|
|
5
|
+
require "../format"
|
|
6
|
+
require "../hls/parser"
|
|
7
|
+
require "../hls/playlist"
|
|
8
|
+
require "../media"
|
|
9
|
+
|
|
10
|
+
module Rill::Extractors
|
|
11
|
+
class Hls < Extractor
|
|
12
|
+
def name : String
|
|
13
|
+
"hls"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def matches?(url : URI) : Bool
|
|
17
|
+
return false unless url.scheme == "http" || url.scheme == "https"
|
|
18
|
+
|
|
19
|
+
File.extname(url.path).downcase == ".m3u8"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def extract(url : URI, client : HTTPClient) : Media
|
|
23
|
+
response = client.get(url)
|
|
24
|
+
playlist = Rill::Hls::Parser.parse(response.body, response.url)
|
|
25
|
+
|
|
26
|
+
case playlist
|
|
27
|
+
when Rill::Hls::MultivariantPlaylist
|
|
28
|
+
extract_master(playlist, url, client)
|
|
29
|
+
when Rill::Hls::MediaPlaylist
|
|
30
|
+
extract_media(playlist, url, response.url)
|
|
31
|
+
else
|
|
32
|
+
raise ManifestError.new("unsupported HLS playlist")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private def extract_master(
|
|
37
|
+
playlist : Rill::Hls::MultivariantPlaylist,
|
|
38
|
+
url : URI,
|
|
39
|
+
client : HTTPClient,
|
|
40
|
+
) : Media
|
|
41
|
+
separate = playlist.variants.any? { |variant| separate_audio?(playlist, variant) }
|
|
42
|
+
container = separate ? detect_container(client, playlist.variants) : nil
|
|
43
|
+
|
|
44
|
+
if separate && container.nil?
|
|
45
|
+
raise UnsupportedManifestError.new("cannot determine HLS container for separate audio")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
formats = playlist.variants.map_with_index do |variant, index|
|
|
49
|
+
kind = separate_audio?(playlist, variant) ? FormatKind::Video : FormatKind::Combined
|
|
50
|
+
format_for_variant(variant, index, kind, container)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
warnings = [] of String
|
|
54
|
+
if separate
|
|
55
|
+
audio = audio_renditions(playlist)
|
|
56
|
+
available = playlist.renditions.count { |rendition| rendition.type == "AUDIO" && rendition.url }
|
|
57
|
+
if audio.size < available
|
|
58
|
+
warnings << "only default audio renditions are exposed"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
audio.each_with_index do |rendition, index|
|
|
62
|
+
formats << format_for_audio(rendition, index, container)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Media.new(
|
|
67
|
+
title: title_for(url),
|
|
68
|
+
webpage_url: url,
|
|
69
|
+
extractor: name,
|
|
70
|
+
formats: formats,
|
|
71
|
+
warnings: warnings,
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private def separate_audio?(
|
|
76
|
+
playlist : Rill::Hls::MultivariantPlaylist,
|
|
77
|
+
variant : Rill::Hls::Variant,
|
|
78
|
+
) : Bool
|
|
79
|
+
group = variant.audio_group
|
|
80
|
+
return false unless group
|
|
81
|
+
|
|
82
|
+
playlist.renditions.any? do |rendition|
|
|
83
|
+
rendition.type == "AUDIO" && rendition.group_id == group && !rendition.url.nil?
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private def audio_renditions(
|
|
88
|
+
playlist : Rill::Hls::MultivariantPlaylist,
|
|
89
|
+
) : Array(Rill::Hls::Rendition)
|
|
90
|
+
referenced = playlist.variants.compact_map(&.audio_group)
|
|
91
|
+
groups = {} of String? => Array(Rill::Hls::Rendition)
|
|
92
|
+
|
|
93
|
+
playlist.renditions.each do |rendition|
|
|
94
|
+
next unless rendition.type == "AUDIO"
|
|
95
|
+
next unless rendition.url
|
|
96
|
+
next unless referenced.includes?(rendition.group_id)
|
|
97
|
+
|
|
98
|
+
list = groups[rendition.group_id]? || begin
|
|
99
|
+
created = [] of Rill::Hls::Rendition
|
|
100
|
+
groups[rendition.group_id] = created
|
|
101
|
+
created
|
|
102
|
+
end
|
|
103
|
+
list << rendition
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
groups.values.compact_map do |group|
|
|
107
|
+
group.find(&.default?) || group.find(&.autoselect?) || group.first?
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private def detect_container(
|
|
112
|
+
client : HTTPClient,
|
|
113
|
+
variants : Array(Rill::Hls::Variant),
|
|
114
|
+
) : String?
|
|
115
|
+
variants.each do |variant|
|
|
116
|
+
response = client.get(variant.url)
|
|
117
|
+
playlist = Rill::Hls::Parser.parse(response.body, response.url)
|
|
118
|
+
next unless playlist.is_a?(Rill::Hls::MediaPlaylist)
|
|
119
|
+
|
|
120
|
+
container = media_container(playlist)
|
|
121
|
+
return container if container
|
|
122
|
+
rescue Rill::Error
|
|
123
|
+
next
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private def extract_media(
|
|
130
|
+
playlist : Rill::Hls::MediaPlaylist,
|
|
131
|
+
url : URI,
|
|
132
|
+
playlist_url : URI,
|
|
133
|
+
) : Media
|
|
134
|
+
format = Format.new(
|
|
135
|
+
format_id: "hls",
|
|
136
|
+
url: playlist_url,
|
|
137
|
+
kind: FormatKind::Combined,
|
|
138
|
+
protocol: FormatProtocol::Hls,
|
|
139
|
+
container: media_container(playlist),
|
|
140
|
+
duration_seconds: playlist.duration_seconds,
|
|
141
|
+
note: "HLS",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
Media.new(
|
|
145
|
+
title: title_for(url),
|
|
146
|
+
webpage_url: url,
|
|
147
|
+
duration_seconds: playlist.duration_seconds,
|
|
148
|
+
extractor: name,
|
|
149
|
+
formats: [format],
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private def format_for_variant(
|
|
154
|
+
variant : Rill::Hls::Variant,
|
|
155
|
+
index : Int32,
|
|
156
|
+
kind : FormatKind,
|
|
157
|
+
container : String?,
|
|
158
|
+
) : Format
|
|
159
|
+
Format.new(
|
|
160
|
+
format_id: "hls-#{index + 1}",
|
|
161
|
+
url: variant.url,
|
|
162
|
+
kind: kind,
|
|
163
|
+
protocol: FormatProtocol::Hls,
|
|
164
|
+
container: kind == FormatKind::Video ? container : nil,
|
|
165
|
+
width: variant.width,
|
|
166
|
+
height: variant.height,
|
|
167
|
+
bitrate: variant.bandwidth,
|
|
168
|
+
note: variant.codecs,
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private def format_for_audio(
|
|
173
|
+
rendition : Rill::Hls::Rendition,
|
|
174
|
+
index : Int32,
|
|
175
|
+
container : String?,
|
|
176
|
+
) : Format
|
|
177
|
+
Format.new(
|
|
178
|
+
format_id: "audio-#{index + 1}",
|
|
179
|
+
url: rendition.url.not_nil!,
|
|
180
|
+
kind: FormatKind::Audio,
|
|
181
|
+
protocol: FormatProtocol::Hls,
|
|
182
|
+
container: container,
|
|
183
|
+
language: rendition.language,
|
|
184
|
+
note: rendition.name,
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private def media_container(playlist : Rill::Hls::MediaPlaylist) : String?
|
|
189
|
+
return "mp4" if playlist.map_url
|
|
190
|
+
|
|
191
|
+
extension = File.extname(playlist.segments.first.url.path).lchop('.').downcase
|
|
192
|
+
extension.empty? ? nil : extension
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private def title_for(url : URI) : String
|
|
196
|
+
basename = File.basename(url.path)
|
|
197
|
+
stem = File.basename(basename, File.extname(basename))
|
|
198
|
+
decoded = URI.decode(stem)
|
|
199
|
+
decoded.empty? ? "stream" : decoded
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|