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,143 @@
1
+ require "./download_plan"
2
+ require "./format"
3
+
4
+ module Rill
5
+ enum FormatPreference
6
+ AudioAndVideo
7
+ AudioOnly
8
+ VideoOnly
9
+ end
10
+ end
11
+
12
+ module Rill::FormatSelector
13
+ extend self
14
+
15
+ CONTAINER_RANK = {
16
+ "mp4" => 5,
17
+ "webm" => 4,
18
+ "mkv" => 4,
19
+ "m4v" => 3,
20
+ "mov" => 3,
21
+ "ogv" => 2,
22
+ "avi" => 1,
23
+ }
24
+
25
+ def best(formats : Array(Format)) : Format?
26
+ return nil if formats.empty?
27
+
28
+ combined = formats.select { |format| format.kind == FormatKind::Combined }
29
+ return best_video(combined) unless combined.empty?
30
+
31
+ video = formats.select { |format| format.kind == FormatKind::Video }
32
+ return best_video(video) unless video.empty?
33
+
34
+ best_audio(formats)
35
+ end
36
+
37
+ def plan(
38
+ formats : Array(Format),
39
+ format_id : String? = nil,
40
+ preference : FormatPreference = FormatPreference::AudioAndVideo,
41
+ ) : DownloadPlan?
42
+ return nil if formats.empty?
43
+
44
+ if format_id
45
+ selected = by_id(formats, format_id)
46
+ return nil unless selected
47
+
48
+ return plan_for(selected, formats)
49
+ end
50
+
51
+ case preference
52
+ when .audio_only?
53
+ audio = best_audio_only(formats)
54
+ audio ? DownloadPlan.new(audio) : nil
55
+ when .video_only?
56
+ video = best_video_only(formats)
57
+ video ? DownloadPlan.new(video) : nil
58
+ else
59
+ audio_and_video_plan(formats)
60
+ end
61
+ end
62
+
63
+ private def audio_and_video_plan(formats : Array(Format)) : DownloadPlan?
64
+ combined = formats.select { |format| format.kind == FormatKind::Combined }
65
+ unless combined.empty?
66
+ return DownloadPlan.new(best_video(combined))
67
+ end
68
+
69
+ video = best_video_only(formats)
70
+ unless video.nil?
71
+ return DownloadPlan.new(video, best_audio_for(video, formats))
72
+ end
73
+
74
+ audio = best_audio_only(formats)
75
+ audio ? DownloadPlan.new(audio) : nil
76
+ end
77
+
78
+ private def best_video_only(formats : Array(Format)) : Format?
79
+ video = formats.select { |format| format.kind == FormatKind::Video }
80
+ return nil if video.empty?
81
+
82
+ best_video(video)
83
+ end
84
+
85
+ private def best_audio_only(formats : Array(Format)) : Format?
86
+ audio = formats.select { |format| format.kind == FormatKind::Audio }
87
+ return nil if audio.empty?
88
+
89
+ audio.max_by? do |format|
90
+ {
91
+ (format.bitrate || 0).to_i64,
92
+ (format.audio_bitrate || 0).to_i64,
93
+ format.filesize || 0_i64,
94
+ }
95
+ end || audio.first
96
+ end
97
+
98
+ def by_id(formats : Array(Format), format_id : String) : Format?
99
+ formats.find { |format| format.format_id == format_id }
100
+ end
101
+
102
+ private def plan_for(selected : Format, formats : Array(Format)) : DownloadPlan
103
+ return DownloadPlan.new(selected) unless selected.kind == FormatKind::Video
104
+
105
+ DownloadPlan.new(selected, best_audio_for(selected, formats))
106
+ end
107
+
108
+ private def best_audio_for(video : Format, formats : Array(Format)) : Format?
109
+ audio = formats.select { |format| format.kind == FormatKind::Audio }
110
+ return nil if audio.empty?
111
+
112
+ audio.max_by? do |format|
113
+ {
114
+ video.container && format.container == video.container ? 1 : 0,
115
+ (format.bitrate || 0).to_i64,
116
+ (format.audio_bitrate || 0).to_i64,
117
+ format.filesize || 0_i64,
118
+ }
119
+ end || audio.first
120
+ end
121
+
122
+ private def best_video(formats : Array(Format)) : Format
123
+ formats.max_by? do |format|
124
+ {
125
+ format.height || 0,
126
+ CONTAINER_RANK[format.container || ""]? || 0,
127
+ (format.video_bitrate || 0).to_i64,
128
+ format.bitrate || 0_i64,
129
+ format.filesize || 0_i64,
130
+ }
131
+ end || formats.first
132
+ end
133
+
134
+ private def best_audio(formats : Array(Format)) : Format
135
+ formats.max_by? do |format|
136
+ {
137
+ (format.bitrate || 0).to_i64,
138
+ (format.audio_bitrate || 0).to_i64,
139
+ format.filesize || 0_i64,
140
+ }
141
+ end || formats.first
142
+ end
143
+ end
@@ -0,0 +1,257 @@
1
+ require "uri"
2
+
3
+ require "../errors"
4
+ require "./playlist"
5
+
6
+ module Rill::Hls::Parser
7
+ extend self
8
+
9
+ STREAM_INF = "#EXT-X-STREAM-INF:"
10
+ MEDIA = "#EXT-X-MEDIA:"
11
+ EXTINF = "#EXTINF:"
12
+ KEY = "#EXT-X-KEY:"
13
+ MAP = "#EXT-X-MAP:"
14
+ BYTERANGE = "#EXT-X-BYTERANGE:"
15
+ ENDLIST = "#EXT-X-ENDLIST"
16
+
17
+ def parse(source : String, base_url : URI) : MultivariantPlaylist | MediaPlaylist
18
+ text = source.lchop('\uFEFF')
19
+ lines = text.lines.map(&.strip).reject(&.empty?)
20
+
21
+ unless lines.first? == "#EXTM3U"
22
+ raise ManifestError.new("HLS playlist is missing #EXTM3U")
23
+ end
24
+
25
+ if lines.any?(&.starts_with?(STREAM_INF))
26
+ parse_master(lines, base_url)
27
+ else
28
+ parse_media(lines, base_url)
29
+ end
30
+ end
31
+
32
+ def parse_attributes(text : String) : Hash(String, String)
33
+ attributes = {} of String => String
34
+ index = 0
35
+
36
+ while index < text.size
37
+ while index < text.size && (text[index] == ' ' || text[index] == ',')
38
+ index += 1
39
+ end
40
+ break if index >= text.size
41
+
42
+ key_start = index
43
+ while index < text.size && text[index] != '='
44
+ index += 1
45
+ end
46
+
47
+ if index >= text.size
48
+ raise ManifestError.new("invalid HLS attribute: #{text}")
49
+ end
50
+
51
+ key = text[key_start, index - key_start].strip
52
+ index += 1
53
+
54
+ value = if index < text.size && text[index] == '"'
55
+ index += 1
56
+ value_start = index
57
+ while index < text.size && text[index] != '"'
58
+ index += 1
59
+ end
60
+
61
+ if index >= text.size
62
+ raise ManifestError.new("unterminated HLS attribute: #{text}")
63
+ end
64
+
65
+ result = text[value_start, index - value_start]
66
+ index += 1
67
+ result
68
+ else
69
+ value_start = index
70
+ while index < text.size && text[index] != ','
71
+ index += 1
72
+ end
73
+ text[value_start, index - value_start].strip
74
+ end
75
+
76
+ attributes[key.upcase] = value unless key.empty?
77
+ end
78
+
79
+ attributes
80
+ end
81
+
82
+ private def parse_master(lines : Array(String), base_url : URI) : MultivariantPlaylist
83
+ variants = [] of Variant
84
+ renditions = [] of Rendition
85
+ pending : Hash(String, String)? = nil
86
+
87
+ lines.each do |line|
88
+ if line.starts_with?(STREAM_INF)
89
+ pending = parse_attributes(line[STREAM_INF.size..])
90
+ next
91
+ end
92
+
93
+ if line.starts_with?(MEDIA)
94
+ renditions << build_rendition(parse_attributes(line[MEDIA.size..]), base_url)
95
+ next
96
+ end
97
+
98
+ next if line.starts_with?('#')
99
+
100
+ if attributes = pending
101
+ variants << build_variant(attributes, line, base_url)
102
+ pending = nil
103
+ end
104
+ end
105
+
106
+ raise ManifestError.new("HLS master playlist has a dangling stream") if pending
107
+ raise ManifestError.new("HLS master playlist has no variants") if variants.empty?
108
+
109
+ MultivariantPlaylist.new(variants, renditions)
110
+ end
111
+
112
+ private def build_rendition(
113
+ attributes : Hash(String, String),
114
+ base_url : URI,
115
+ ) : Rendition
116
+ type = attributes["TYPE"]?
117
+ raise ManifestError.new("HLS media rendition is missing a type") unless type
118
+
119
+ uri = attributes["URI"]?
120
+
121
+ Rendition.new(
122
+ type: type.upcase,
123
+ group_id: attributes["GROUP-ID"]?,
124
+ name: attributes["NAME"]?,
125
+ language: attributes["LANGUAGE"]?,
126
+ url: uri ? resolve(uri, base_url) : nil,
127
+ default: attributes["DEFAULT"]? == "YES",
128
+ autoselect: attributes["AUTOSELECT"]? == "YES",
129
+ )
130
+ end
131
+
132
+ private def build_variant(
133
+ attributes : Hash(String, String),
134
+ uri_text : String,
135
+ base_url : URI,
136
+ ) : Variant
137
+ width = nil
138
+ height = nil
139
+
140
+ if resolution = attributes["RESOLUTION"]?
141
+ if match = /\A(\d+)x(\d+)\z/.match(resolution)
142
+ width = match[1].to_i?
143
+ height = match[2].to_i?
144
+ end
145
+ end
146
+
147
+ Variant.new(
148
+ url: resolve(uri_text, base_url),
149
+ bandwidth: parse_i64(attributes["BANDWIDTH"]?),
150
+ average_bandwidth: parse_i64(attributes["AVERAGE-BANDWIDTH"]?),
151
+ width: width,
152
+ height: height,
153
+ codecs: attributes["CODECS"]?,
154
+ audio_group: attributes["AUDIO"]?,
155
+ subtitle_group: attributes["SUBTITLES"]?,
156
+ )
157
+ end
158
+
159
+ private def parse_media(lines : Array(String), base_url : URI) : MediaPlaylist
160
+ segments = [] of Segment
161
+ map_url : URI? = nil
162
+ map_byte_range : ByteRange? = nil
163
+ encrypted = false
164
+ ended = false
165
+ duration = 0.0
166
+ pending_duration = 0.0
167
+ pending_title : String? = nil
168
+ pending_range : ByteRange? = nil
169
+
170
+ lines.each do |line|
171
+ case
172
+ when line.starts_with?(EXTINF)
173
+ pending_duration, pending_title = parse_extinf(line[EXTINF.size..])
174
+ when line.starts_with?(KEY)
175
+ attributes = parse_attributes(line[KEY.size..])
176
+ method = attributes["METHOD"]?
177
+ encrypted = true unless method.nil? || method == "NONE"
178
+ when line.starts_with?(MAP)
179
+ attributes = parse_attributes(line[MAP.size..])
180
+ uri = attributes["URI"]?
181
+ raise ManifestError.new("HLS map section is missing a URI") unless uri
182
+
183
+ map_url = resolve(uri, base_url)
184
+ if range = attributes["BYTERANGE"]?
185
+ map_byte_range = parse_byte_range(range)
186
+ end
187
+ when line.starts_with?(BYTERANGE)
188
+ pending_range = parse_byte_range(line[BYTERANGE.size..])
189
+ when line.starts_with?(ENDLIST)
190
+ ended = true
191
+ when line.starts_with?('#')
192
+ next
193
+ else
194
+ segments << Segment.new(
195
+ url: resolve(line, base_url),
196
+ duration: pending_duration,
197
+ title: pending_title,
198
+ byte_range: pending_range,
199
+ )
200
+ duration += pending_duration
201
+ pending_duration = 0.0
202
+ pending_title = nil
203
+ pending_range = nil
204
+ end
205
+ end
206
+
207
+ raise ManifestError.new("HLS media playlist has no segments") if segments.empty?
208
+
209
+ MediaPlaylist.new(
210
+ segments: segments,
211
+ duration_seconds: duration,
212
+ ended: ended,
213
+ encrypted: encrypted,
214
+ map_url: map_url,
215
+ map_byte_range: map_byte_range,
216
+ )
217
+ end
218
+
219
+ private def parse_extinf(text : String) : Tuple(Float64, String?)
220
+ duration_text, comma, title = text.partition(',')
221
+ duration = duration_text.to_f?
222
+ unless duration && duration.finite? && duration >= 0
223
+ raise ManifestError.new("invalid HLS segment duration: #{duration_text}")
224
+ end
225
+
226
+ parsed_title = comma.empty? || title.empty? ? nil : title
227
+ {duration, parsed_title}
228
+ end
229
+
230
+ private def parse_byte_range(value : String) : ByteRange
231
+ length_text, separator, offset_text = value.strip.partition('@')
232
+ length = length_text.to_i64?
233
+ raise ManifestError.new("invalid HLS byte range: #{value}") unless length
234
+
235
+ offset = nil
236
+ unless separator.empty?
237
+ offset = offset_text.to_i64?
238
+ raise ManifestError.new("invalid HLS byte range offset: #{value}") unless offset
239
+ end
240
+
241
+ ByteRange.new(length, offset)
242
+ end
243
+
244
+ private def parse_i64(value : String?) : Int64?
245
+ value.try(&.to_i64?)
246
+ end
247
+
248
+ private def resolve(value : String, base_url : URI) : URI
249
+ uri = base_url.resolve(value)
250
+ unless uri.scheme == "http" || uri.scheme == "https"
251
+ raise ManifestError.new("unsupported HLS URI: #{value}")
252
+ end
253
+ uri
254
+ rescue URI::Error
255
+ raise ManifestError.new("invalid HLS URI: #{value}")
256
+ end
257
+ end
@@ -0,0 +1,100 @@
1
+ require "uri"
2
+
3
+ module Rill::Hls
4
+ struct ByteRange
5
+ getter length : Int64
6
+ getter offset : Int64?
7
+
8
+ def initialize(@length : Int64, @offset : Int64? = nil)
9
+ end
10
+ end
11
+
12
+ struct Variant
13
+ getter url : URI
14
+ getter bandwidth : Int64?
15
+ getter average_bandwidth : Int64?
16
+ getter width : Int32?
17
+ getter height : Int32?
18
+ getter codecs : String?
19
+ getter audio_group : String?
20
+ getter subtitle_group : String?
21
+
22
+ def initialize(
23
+ @url : URI,
24
+ @bandwidth : Int64? = nil,
25
+ @average_bandwidth : Int64? = nil,
26
+ @width : Int32? = nil,
27
+ @height : Int32? = nil,
28
+ @codecs : String? = nil,
29
+ @audio_group : String? = nil,
30
+ @subtitle_group : String? = nil,
31
+ )
32
+ end
33
+ end
34
+
35
+ struct Segment
36
+ getter url : URI
37
+ getter duration : Float64
38
+ getter title : String?
39
+ getter byte_range : ByteRange?
40
+
41
+ def initialize(
42
+ @url : URI,
43
+ @duration : Float64 = 0.0,
44
+ @title : String? = nil,
45
+ @byte_range : ByteRange? = nil,
46
+ )
47
+ end
48
+ end
49
+
50
+ struct Rendition
51
+ getter type : String
52
+ getter group_id : String?
53
+ getter name : String?
54
+ getter language : String?
55
+ getter url : URI?
56
+ getter? default : Bool
57
+ getter? autoselect : Bool
58
+
59
+ def initialize(
60
+ @type : String,
61
+ @group_id : String? = nil,
62
+ @name : String? = nil,
63
+ @language : String? = nil,
64
+ @url : URI? = nil,
65
+ @default : Bool = false,
66
+ @autoselect : Bool = false,
67
+ )
68
+ end
69
+ end
70
+
71
+ struct MediaPlaylist
72
+ getter segments : Array(Segment)
73
+ getter map_url : URI?
74
+ getter map_byte_range : ByteRange?
75
+ getter duration_seconds : Float64
76
+ getter ended : Bool
77
+ getter encrypted : Bool
78
+
79
+ def initialize(
80
+ @segments : Array(Segment),
81
+ @duration_seconds : Float64,
82
+ @ended : Bool,
83
+ @encrypted : Bool,
84
+ @map_url : URI? = nil,
85
+ @map_byte_range : ByteRange? = nil,
86
+ )
87
+ end
88
+ end
89
+
90
+ struct MultivariantPlaylist
91
+ getter variants : Array(Variant)
92
+ getter renditions : Array(Rendition)
93
+
94
+ def initialize(
95
+ @variants : Array(Variant),
96
+ @renditions : Array(Rendition) = [] of Rendition,
97
+ )
98
+ end
99
+ end
100
+ end