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,325 @@
|
|
|
1
|
+
require "html"
|
|
2
|
+
require "json"
|
|
3
|
+
require "set"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
require "../errors"
|
|
7
|
+
require "../extractor"
|
|
8
|
+
require "../format"
|
|
9
|
+
require "../http_url"
|
|
10
|
+
require "../json_value"
|
|
11
|
+
require "../media"
|
|
12
|
+
|
|
13
|
+
module Rill::Extractors
|
|
14
|
+
class Rumble < Extractor
|
|
15
|
+
DEFAULT_BASE_URL = "https://rumble.com"
|
|
16
|
+
NORMAL_PATH = %r{\A/(v(?!ideos\b)[A-Za-z0-9]+)(?:-[^/]*)?(?:\.html)?/?\z}
|
|
17
|
+
EMBED_PATH = %r{\A/embed/(v[A-Za-z0-9]+)/?\z}
|
|
18
|
+
EMBED_REFERENCE = %r{(?:(?:https?:)?//(?:www\.)?rumble\.com)?/embed/(v[A-Za-z0-9]+)}i
|
|
19
|
+
TITLE_PATTERN = %r{<title[^>]*>(.*?)</title>}mi
|
|
20
|
+
TITLE_AUTHOR = /\A.+?\s-\s([^-]+?)\s-\sRumble\z/i
|
|
21
|
+
ARTIST_PATTERN = /"content_artist"\s*:\s*"((?:\\.|[^"\\])*)"/
|
|
22
|
+
THUMBNAIL_PATTERN = /"i"\s*:\s*"((?:\\.|[^"\\])*)"/
|
|
23
|
+
DURATION_PATTERN = /"duration"\s*:\s*(\d+)/
|
|
24
|
+
|
|
25
|
+
def initialize(@base_url : String = DEFAULT_BASE_URL)
|
|
26
|
+
@base_url = @base_url.rstrip('/')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def name : String
|
|
30
|
+
"rumble"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def matches?(url : URI) : Bool
|
|
34
|
+
return false unless url.scheme == "http" || url.scheme == "https"
|
|
35
|
+
return false unless host?(url.host)
|
|
36
|
+
|
|
37
|
+
!identifier_for(url).nil?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def extract(url : URI, client : HTTPClient) : Media
|
|
41
|
+
identifier = identifier_for(url)
|
|
42
|
+
unless identifier
|
|
43
|
+
raise UnsupportedUrlError.new(url.to_s)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
embed_id = embed_identifier(url, client)
|
|
47
|
+
embed_url = URI.parse("#{@base_url}/embed/#{embed_id}")
|
|
48
|
+
body = fetch(client, embed_url, url)
|
|
49
|
+
config = config_for(body, embed_id)
|
|
50
|
+
raw_title = raw_title(body)
|
|
51
|
+
|
|
52
|
+
formats = formats_from(config)
|
|
53
|
+
if formats.empty?
|
|
54
|
+
raise MediaUnavailableError.new(url.to_s, "no playable variants were found")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Media.new(
|
|
58
|
+
id: embed_id,
|
|
59
|
+
title: title_for(raw_title, embed_id),
|
|
60
|
+
webpage_url: url,
|
|
61
|
+
uploader: uploader_for(body, raw_title),
|
|
62
|
+
duration_seconds: duration_for(config, body),
|
|
63
|
+
thumbnail_url: thumbnail_for(config, body),
|
|
64
|
+
extractor: name,
|
|
65
|
+
formats: formats,
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private def identifier_for(url : URI) : String?
|
|
70
|
+
path = url.path
|
|
71
|
+
match = NORMAL_PATH.match(path) || EMBED_PATH.match(path)
|
|
72
|
+
match.try(&.[1])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private def embed_identifier(url : URI, client : HTTPClient) : String
|
|
76
|
+
identifier = identifier_for(url).not_nil!
|
|
77
|
+
return identifier if EMBED_PATH.match(url.path)
|
|
78
|
+
|
|
79
|
+
body = fetch(client, url, url)
|
|
80
|
+
match = EMBED_REFERENCE.match(body)
|
|
81
|
+
unless match
|
|
82
|
+
raise MediaUnavailableError.new(url.to_s, "page has no embed URL")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
match[1]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private def fetch(client : HTTPClient, url : URI, original_url : URI) : String
|
|
89
|
+
client.get(url).body
|
|
90
|
+
rescue ex : HTTPError
|
|
91
|
+
if ex.status.code == 404 || ex.status.code == 410
|
|
92
|
+
raise MediaUnavailableError.new(original_url.to_s, "page is unavailable (#{ex.status.code})")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
raise ex
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private def host?(host : String?) : Bool
|
|
99
|
+
case host.try(&.downcase)
|
|
100
|
+
when "rumble.com", "www.rumble.com"
|
|
101
|
+
true
|
|
102
|
+
else
|
|
103
|
+
false
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private def raw_title(body : String) : String
|
|
108
|
+
match = TITLE_PATTERN.match(body)
|
|
109
|
+
match ? HTML.unescape(match[1]).strip : ""
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private def title_for(raw_title : String, identifier : String) : String
|
|
113
|
+
title = raw_title.sub(/\s*-\s*Rumble\z/i, "").strip
|
|
114
|
+
title.empty? ? identifier : title
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private def uploader_for(body : String, raw_title : String) : String?
|
|
118
|
+
if match = ARTIST_PATTERN.match(body)
|
|
119
|
+
artist = json_unescape(match[1])
|
|
120
|
+
return artist unless artist.try(&.empty?)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
title_author(raw_title)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private def title_author(raw_title : String) : String?
|
|
127
|
+
match = TITLE_AUTHOR.match(raw_title)
|
|
128
|
+
return nil unless match
|
|
129
|
+
|
|
130
|
+
author = HTML.unescape(match[1]).strip
|
|
131
|
+
author.empty? ? nil : author
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private def thumbnail_for(config : Hash(String, JSON::Any)?, body : String) : URI?
|
|
135
|
+
value = config.try { |hash| JsonValue.string(hash["i"]?) }
|
|
136
|
+
|
|
137
|
+
unless value
|
|
138
|
+
if match = THUMBNAIL_PATTERN.match(body)
|
|
139
|
+
value = json_unescape(match[1])
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
value ? HttpUrl.absolute(value) : nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private def duration_for(config : Hash(String, JSON::Any)?, body : String) : Float64?
|
|
147
|
+
if config
|
|
148
|
+
duration = JsonValue.seconds(config["duration"]?)
|
|
149
|
+
return duration if duration
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
match = DURATION_PATTERN.match(body)
|
|
153
|
+
return nil unless match
|
|
154
|
+
|
|
155
|
+
match[1].to_f64?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private def config_for(body : String, identifier : String) : Hash(String, JSON::Any)?
|
|
159
|
+
marker = "p.f[#{identifier.to_json}]"
|
|
160
|
+
marker_index = body.index(marker)
|
|
161
|
+
return nil unless marker_index
|
|
162
|
+
|
|
163
|
+
equals_index = body.index('=', marker_index + marker.size)
|
|
164
|
+
return nil unless equals_index
|
|
165
|
+
|
|
166
|
+
object_start = body.index('{', equals_index)
|
|
167
|
+
return nil unless object_start
|
|
168
|
+
|
|
169
|
+
object_end = matching_brace(body, object_start)
|
|
170
|
+
return nil unless object_end
|
|
171
|
+
|
|
172
|
+
json = body[object_start..object_end].sub(/,\s*"?loaded"?\s*:\s*v\(\)(?=\s*\})/, "")
|
|
173
|
+
JSON.parse(json).as_h?
|
|
174
|
+
rescue JSON::ParseException
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private def matching_brace(body : String, start : Int32) : Int32?
|
|
179
|
+
depth = 0
|
|
180
|
+
in_string = false
|
|
181
|
+
escaped = false
|
|
182
|
+
index = start
|
|
183
|
+
|
|
184
|
+
while index < body.size
|
|
185
|
+
char = body[index]
|
|
186
|
+
|
|
187
|
+
if in_string
|
|
188
|
+
if escaped
|
|
189
|
+
escaped = false
|
|
190
|
+
elsif char == '\\'
|
|
191
|
+
escaped = true
|
|
192
|
+
elsif char == '"'
|
|
193
|
+
in_string = false
|
|
194
|
+
end
|
|
195
|
+
else
|
|
196
|
+
case char
|
|
197
|
+
when '"'
|
|
198
|
+
in_string = true
|
|
199
|
+
when '{'
|
|
200
|
+
depth += 1
|
|
201
|
+
when '}'
|
|
202
|
+
depth -= 1
|
|
203
|
+
return index if depth.zero?
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
index += 1
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
private def formats_from(config : Hash(String, JSON::Any)?) : Array(Format)
|
|
214
|
+
formats = [] of Format
|
|
215
|
+
return formats unless config
|
|
216
|
+
|
|
217
|
+
seen_urls = Set(String).new
|
|
218
|
+
seen_ids = Set(String).new
|
|
219
|
+
|
|
220
|
+
if ua = config["ua"]?.try(&.as_h?)
|
|
221
|
+
ua.each do |container, qualities_value|
|
|
222
|
+
next unless supported_container?(container)
|
|
223
|
+
|
|
224
|
+
qualities = qualities_value.as_h?
|
|
225
|
+
next unless qualities
|
|
226
|
+
|
|
227
|
+
qualities.each do |quality, entry_value|
|
|
228
|
+
entry = entry_value.as_h?
|
|
229
|
+
next unless entry
|
|
230
|
+
|
|
231
|
+
append_variant(formats, seen_urls, seen_ids, entry, container, quality, false)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
if base = config["u"]?.try(&.as_h?)
|
|
237
|
+
base.each do |container, entry_value|
|
|
238
|
+
next unless supported_container?(container)
|
|
239
|
+
|
|
240
|
+
entry = entry_value.as_h?
|
|
241
|
+
next unless entry
|
|
242
|
+
|
|
243
|
+
append_variant(formats, seen_urls, seen_ids, entry, container, nil, true)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
formats
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private def supported_container?(container : String) : Bool
|
|
251
|
+
container == "mp4" || container == "webm"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
private def append_variant(
|
|
255
|
+
formats : Array(Format),
|
|
256
|
+
seen_urls : Set(String),
|
|
257
|
+
seen_ids : Set(String),
|
|
258
|
+
entry : Hash(String, JSON::Any),
|
|
259
|
+
container : String,
|
|
260
|
+
quality : String?,
|
|
261
|
+
base : Bool,
|
|
262
|
+
) : Nil
|
|
263
|
+
url_text = JsonValue.string(entry["url"]?)
|
|
264
|
+
return unless url_text
|
|
265
|
+
|
|
266
|
+
url = HttpUrl.absolute(url_text)
|
|
267
|
+
return unless url
|
|
268
|
+
|
|
269
|
+
url_key = url.to_s
|
|
270
|
+
return if seen_urls.includes?(url_key)
|
|
271
|
+
|
|
272
|
+
meta = entry["meta"]?.try(&.as_h?) || {} of String => JSON::Any
|
|
273
|
+
format_id = variant_id(seen_ids, container, quality, meta, base)
|
|
274
|
+
|
|
275
|
+
seen_urls << url_key
|
|
276
|
+
seen_ids << format_id
|
|
277
|
+
formats << Format.new(
|
|
278
|
+
format_id: format_id,
|
|
279
|
+
url: url,
|
|
280
|
+
kind: FormatKind::Combined,
|
|
281
|
+
protocol: FormatProtocol::Http,
|
|
282
|
+
container: container,
|
|
283
|
+
bitrate: non_negative(JsonValue.integer(meta["bitrate"]?)),
|
|
284
|
+
width: JsonValue.dimension(meta["w"]?),
|
|
285
|
+
height: JsonValue.dimension(meta["h"]?),
|
|
286
|
+
filesize: non_negative(JsonValue.integer(meta["size"]?)),
|
|
287
|
+
note: base ? "base" : nil,
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
private def variant_id(
|
|
292
|
+
seen_ids : Set(String),
|
|
293
|
+
container : String,
|
|
294
|
+
quality : String?,
|
|
295
|
+
meta : Hash(String, JSON::Any),
|
|
296
|
+
base : Bool,
|
|
297
|
+
) : String
|
|
298
|
+
suffix = if base
|
|
299
|
+
"base"
|
|
300
|
+
else
|
|
301
|
+
quality || JsonValue.dimension(meta["h"]?).try(&.to_s) || "video"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
candidate = "#{container}-#{suffix}"
|
|
305
|
+
return candidate unless seen_ids.includes?(candidate)
|
|
306
|
+
|
|
307
|
+
index = 2
|
|
308
|
+
while seen_ids.includes?("#{candidate}-#{index}")
|
|
309
|
+
index += 1
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
"#{candidate}-#{index}"
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
private def non_negative(value : Int64?) : Int64?
|
|
316
|
+
value if value && value >= 0
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
private def json_unescape(value : String) : String?
|
|
320
|
+
JSON.parse(%("#{value}")).as_s?
|
|
321
|
+
rescue JSON::ParseException
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|