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