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,184 @@
1
+ require "json"
2
+ require "uri"
3
+
4
+ require "../errors"
5
+ require "../extractor"
6
+ require "../http_url"
7
+ require "../json_value"
8
+ require "../media"
9
+ require "./youtube"
10
+
11
+ module Rill::Extractors
12
+ class Spotify < Extractor
13
+ DEFAULT_API_BASE = "https://open.spotify.com"
14
+ TRACK_PATH = %r{\A/(?:intl-[a-z-]+/)?track/([A-Za-z0-9]{22})/?\z}
15
+ USER_AGENT = "Mozilla/5.0"
16
+
17
+ def initialize(
18
+ @api_base : String = DEFAULT_API_BASE,
19
+ @youtube : YouTube = YouTube.new,
20
+ )
21
+ end
22
+
23
+ def name : String
24
+ "spotify"
25
+ end
26
+
27
+ def matches?(url : URI) : Bool
28
+ return false unless url.scheme == "http" || url.scheme == "https"
29
+
30
+ host = url.host.try(&.downcase)
31
+ return false unless host == "open.spotify.com" || host == "www.open.spotify.com"
32
+
33
+ !track_id_for(url).nil?
34
+ end
35
+
36
+ def extract(url : URI, client : HTTPClient) : Media
37
+ track_id = track_id_for(url)
38
+ unless track_id
39
+ raise UnsupportedUrlError.new(url.to_s)
40
+ end
41
+
42
+ entity = fetch_entity(client, track_id, url)
43
+ if entity["isPlayable"]?.try(&.as_bool?) == false
44
+ raise MediaUnavailableError.new(url.to_s, "track is not playable")
45
+ end
46
+
47
+ title = JsonValue.string(entity["title"]?) || JsonValue.string(entity["name"]?) || track_id
48
+ artists = artist_names(entity)
49
+ duration = duration_seconds(entity)
50
+ query = [title, artists].compact.join(" ").strip
51
+
52
+ matches = @youtube.search(query, client, 10)
53
+ if matches.empty?
54
+ raise MediaUnavailableError.new(url.to_s, "could not find a YouTube match")
55
+ end
56
+
57
+ match = select_match(matches, duration)
58
+ youtube_media = @youtube.extract(
59
+ URI.parse("https://www.youtube.com/watch?v=#{match.video_id}"),
60
+ client,
61
+ )
62
+
63
+ warnings = [
64
+ "Spotify provides no DRM-free stream; formats were resolved from YouTube: " \
65
+ "#{match.title} (https://youtu.be/#{match.video_id}).",
66
+ ]
67
+
68
+ if duration && match.duration_seconds
69
+ difference = (duration - match.duration_seconds.not_nil!).abs
70
+ if difference > 15
71
+ warnings << "The matched YouTube duration differs by #{difference.round.to_i} seconds; verify the result."
72
+ end
73
+ end
74
+
75
+ Media.new(
76
+ id: track_id,
77
+ title: title,
78
+ webpage_url: url,
79
+ uploader: artists,
80
+ duration_seconds: duration,
81
+ thumbnail_url: thumbnail_url(entity),
82
+ upload_date: release_date(entity),
83
+ extractor: name,
84
+ formats: youtube_media.formats,
85
+ warnings: warnings,
86
+ )
87
+ end
88
+
89
+ private def fetch_entity(client : HTTPClient, track_id : String, url : URI) : Hash(String, JSON::Any)
90
+ headers = HTTP::Headers.new
91
+ headers["User-Agent"] = USER_AGENT
92
+ headers["Accept-Language"] = "en-US,en;q=0.9"
93
+
94
+ response = client.get("#{@api_base}/embed/track/#{track_id}", headers)
95
+ match = /<script id="__NEXT_DATA__" type="application\/json">(.*?)<\/script>/m.match(response.body)
96
+ unless match
97
+ raise MediaUnavailableError.new(url.to_s, "Spotify embed data is missing")
98
+ end
99
+
100
+ payload = JSON.parse(match[1])
101
+ entity = dig(payload, ["props", "pageProps", "state", "data", "entity"]).try(&.as_h?)
102
+ unless entity
103
+ raise MediaUnavailableError.new(url.to_s, "Spotify track data is missing")
104
+ end
105
+
106
+ entity
107
+ rescue JSON::ParseException
108
+ raise MediaUnavailableError.new(url.to_s, "Spotify embed data is not valid JSON")
109
+ end
110
+
111
+ private def track_id_for(url : URI) : String?
112
+ match = TRACK_PATH.match(url.path)
113
+ return nil unless match
114
+
115
+ match[1]
116
+ end
117
+
118
+ private def artist_names(entity : Hash(String, JSON::Any)) : String?
119
+ artists = entity["artists"]?.try(&.as_a?)
120
+ return nil unless artists
121
+
122
+ names = artists.compact_map do |artist|
123
+ artist.as_h?.try { |hash| JsonValue.string(hash["name"]?) }
124
+ end
125
+ names.empty? ? nil : names.join(", ")
126
+ end
127
+
128
+ private def duration_seconds(entity : Hash(String, JSON::Any)) : Float64?
129
+ milliseconds = entity["duration"]?.try(&.as_i64?)
130
+ return nil unless milliseconds && milliseconds > 0
131
+
132
+ milliseconds / 1000.0
133
+ end
134
+
135
+ private def release_date(entity : Hash(String, JSON::Any)) : String?
136
+ release = entity["releaseDate"]?.try(&.as_h?)
137
+ value = release.try { |hash| JsonValue.string(hash["isoString"]?) }
138
+ return nil unless value
139
+ return nil if value.size < 10
140
+
141
+ value[0, 10]
142
+ end
143
+
144
+ private def thumbnail_url(entity : Hash(String, JSON::Any)) : URI?
145
+ identity = entity["visualIdentity"]?.try(&.as_h?)
146
+ images = identity.try { |hash| hash["image"]?.try(&.as_a?) }
147
+ return nil unless images
148
+
149
+ image = images.max_by? do |item|
150
+ item.as_h?.try { |hash| hash["maxWidth"]?.try(&.as_i64?) } || 0_i64
151
+ end
152
+ return nil unless image
153
+
154
+ value = image.as_h?.try { |hash| JsonValue.string(hash["url"]?) }
155
+ HttpUrl.absolute(value)
156
+ end
157
+
158
+ private def select_match(
159
+ matches : Array(YouTube::SearchResult),
160
+ duration : Float64?,
161
+ ) : YouTube::SearchResult
162
+ return matches.first unless duration
163
+
164
+ matches.min_by? do |match|
165
+ if match_duration = match.duration_seconds
166
+ (match_duration - duration).abs
167
+ else
168
+ Float64::MAX
169
+ end
170
+ end || matches.first
171
+ end
172
+
173
+ private def dig(value : JSON::Any?, keys : Array(String)) : JSON::Any?
174
+ keys.each do |key|
175
+ hash = value.try(&.as_h?)
176
+ return nil unless hash
177
+
178
+ value = hash[key]?
179
+ end
180
+
181
+ value
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,273 @@
1
+ require "http/params"
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 TwitchClip < Extractor
15
+ DEFAULT_ENDPOINT = "https://gql.twitch.tv/gql"
16
+ CLIENT_ID = "ue6666qo983tsx6so1t0vnawi233wa"
17
+ OPERATION_HASH = "2db6a3b20eabf510bd3cf465ae2408834b59eb6b8af89ca73ab1486cacecfb63"
18
+ SLUG_PATTERN = /\A[A-Za-z0-9_-]+\z/
19
+ CLIP_PATH = %r{\A/([A-Za-z0-9_-]+)/?\z}
20
+ CHANNEL_CLIP_PATH = %r{\A/[^/]+/clip/([A-Za-z0-9_-]+)/?\z}
21
+
22
+ def initialize(@endpoint : String = DEFAULT_ENDPOINT)
23
+ end
24
+
25
+ def name : String
26
+ "twitch_clip"
27
+ end
28
+
29
+ def matches?(url : URI) : Bool
30
+ return false unless url.scheme == "http" || url.scheme == "https"
31
+
32
+ !identifier_for(url).nil?
33
+ end
34
+
35
+ def extract(url : URI, client : HTTPClient) : Media
36
+ slug = identifier_for(url)
37
+ unless slug
38
+ raise UnsupportedUrlError.new(url.to_s)
39
+ end
40
+
41
+ clip = clip_data(slug, client, url)
42
+ formats = formats_from(clip)
43
+
44
+ if formats.empty?
45
+ raise MediaUnavailableError.new(url.to_s, "no playable formats were found")
46
+ end
47
+
48
+ Media.new(
49
+ id: JsonValue.string(clip["id"]?) || slug,
50
+ title: JsonValue.string(clip["title"]?) || slug,
51
+ webpage_url: url,
52
+ uploader: uploader_for(clip),
53
+ duration_seconds: JsonValue.seconds(clip["duration"]?),
54
+ thumbnail_url: thumbnail_for(clip),
55
+ upload_date: upload_date_for(clip),
56
+ extractor: name,
57
+ formats: formats,
58
+ )
59
+ end
60
+
61
+ private def identifier_for(url : URI) : String?
62
+ if clips_host?(url.host)
63
+ clips_identifier(url)
64
+ elsif twitch_host?(url.host)
65
+ channel_identifier(url)
66
+ end
67
+ end
68
+
69
+ private def clips_identifier(url : URI) : String?
70
+ return embed_identifier(url) if url.path == "/embed"
71
+
72
+ slug = CLIP_PATH.match(url.path).try(&.[1])
73
+ return nil if slug == "embed"
74
+
75
+ slug
76
+ end
77
+
78
+ private def embed_identifier(url : URI) : String?
79
+ slug = HTTP::Params.parse(url.query || "")["clip"]?
80
+ return nil unless slug
81
+
82
+ slug if SLUG_PATTERN.matches?(slug)
83
+ end
84
+
85
+ private def channel_identifier(url : URI) : String?
86
+ CHANNEL_CLIP_PATH.match(url.path).try(&.[1])
87
+ end
88
+
89
+ private def clips_host?(host : String?) : Bool
90
+ host.try(&.downcase) == "clips.twitch.tv"
91
+ end
92
+
93
+ private def twitch_host?(host : String?) : Bool
94
+ case host.try(&.downcase)
95
+ when "twitch.tv", "www.twitch.tv", "go.twitch.tv", "m.twitch.tv"
96
+ true
97
+ else
98
+ false
99
+ end
100
+ end
101
+
102
+ private def clip_data(
103
+ slug : String,
104
+ client : HTTPClient,
105
+ url : URI,
106
+ ) : Hash(String, JSON::Any)
107
+ response = client.post(@endpoint, request_body(slug), request_headers)
108
+ clip = parsed_clip(response.body)
109
+
110
+ unless clip
111
+ raise MediaUnavailableError.new(url.to_s, "clip is unavailable")
112
+ end
113
+
114
+ clip
115
+ rescue JSON::ParseException
116
+ raise MediaUnavailableError.new(url.to_s, "clip response is not valid JSON")
117
+ end
118
+
119
+ private def request_headers : HTTP::Headers
120
+ headers = HTTP::Headers.new
121
+ headers["Client-ID"] = CLIENT_ID
122
+ headers["Content-Type"] = "text/plain;charset=UTF-8"
123
+ headers
124
+ end
125
+
126
+ private def request_body(slug : String) : String
127
+ JSON.build do |json|
128
+ json.array do
129
+ json.object do
130
+ json.field "operationName", "ShareClipRenderStatus"
131
+ json.field "variables" do
132
+ json.object do
133
+ json.field "slug", slug
134
+ end
135
+ end
136
+ json.field "extensions" do
137
+ json.object do
138
+ json.field "persistedQuery" do
139
+ json.object do
140
+ json.field "version", 1
141
+ json.field "sha256Hash", OPERATION_HASH
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+
151
+ private def parsed_clip(body : String) : Hash(String, JSON::Any)?
152
+ payload = JSON.parse(body).as_a?
153
+ return nil unless payload
154
+
155
+ element = payload.first?.try(&.as_h?)
156
+ return nil unless element
157
+
158
+ data = element["data"]?.try(&.as_h?)
159
+ return nil unless data
160
+
161
+ data["clip"]?.try(&.as_h?)
162
+ end
163
+
164
+ private def formats_from(clip : Hash(String, JSON::Any)) : Array(Format)
165
+ token = clip["playbackAccessToken"]?.try(&.as_h?)
166
+ signature = JsonValue.string(token.try(&.["signature"]?))
167
+ value = JsonValue.string(token.try(&.["value"]?))
168
+ return [] of Format unless signature && value
169
+
170
+ formats = [] of Format
171
+ seen = Set(String).new
172
+ assets = clip["assets"]?.try(&.as_a?) || [] of JSON::Any
173
+
174
+ assets.each_with_index do |asset_value, index|
175
+ asset = asset_value.as_h?
176
+ next unless asset
177
+
178
+ append_asset(formats, seen, asset, index == 1, signature, value)
179
+ end
180
+
181
+ formats
182
+ end
183
+
184
+ private def append_asset(
185
+ formats : Array(Format),
186
+ seen : Set(String),
187
+ asset : Hash(String, JSON::Any),
188
+ portrait : Bool,
189
+ signature : String,
190
+ token : String,
191
+ ) : Nil
192
+ qualities = asset["videoQualities"]?.try(&.as_a?) || [] of JSON::Any
193
+
194
+ qualities.each do |quality_value|
195
+ quality = quality_value.as_h?
196
+ next unless quality
197
+
198
+ source_url = JsonValue.string(quality["sourceURL"]?)
199
+ signed_url = sign_url(source_url, signature, token)
200
+ next unless signed_url
201
+
202
+ url_key = signed_url.to_s
203
+ next if seen.includes?(url_key)
204
+
205
+ quality_label = JsonValue.string(quality["quality"]?)
206
+ next unless quality_label
207
+
208
+ seen << url_key
209
+ formats << Format.new(
210
+ format_id: portrait ? "portrait-#{quality_label}" : quality_label,
211
+ url: signed_url,
212
+ kind: FormatKind::Combined,
213
+ protocol: FormatProtocol::Http,
214
+ container: "mp4",
215
+ height: height_for(quality_label),
216
+ fps: fps_for(quality),
217
+ )
218
+ end
219
+ end
220
+
221
+ private def sign_url(source_url : String?, signature : String, token : String) : URI?
222
+ url = HttpUrl.absolute(source_url)
223
+ return nil unless url
224
+
225
+ params = HTTP::Params.parse(url.query || "")
226
+ params["sig"] = signature
227
+ params["token"] = token
228
+ url.query = params.to_s
229
+ url
230
+ rescue URI::Error
231
+ nil
232
+ end
233
+
234
+ private def height_for(quality : String) : Int32?
235
+ height = quality.to_i?
236
+ height if height && height > 0
237
+ end
238
+
239
+ private def fps_for(quality : Hash(String, JSON::Any)) : Float64?
240
+ fps = JsonValue.number(quality["frameRate"]?)
241
+ fps if fps && fps > 0
242
+ end
243
+
244
+ private def thumbnail_for(clip : Hash(String, JSON::Any)) : URI?
245
+ asset = clip["assets"]?.try(&.as_a?).try(&.first?).try(&.as_h?)
246
+ return nil unless asset
247
+
248
+ HttpUrl.absolute(JsonValue.string(asset["thumbnailURL"]?))
249
+ end
250
+
251
+ private def uploader_for(clip : Hash(String, JSON::Any)) : String?
252
+ curator = clip["curator"]?.try(&.as_h?)
253
+ uploader = presence(JsonValue.string(curator.try(&.["displayName"]?))) ||
254
+ presence(JsonValue.string(curator.try(&.["login"]?)))
255
+ return uploader if uploader
256
+
257
+ broadcaster = clip["broadcaster"]?.try(&.as_h?)
258
+ presence(JsonValue.string(broadcaster.try(&.["displayName"]?)))
259
+ end
260
+
261
+ private def presence(value : String?) : String?
262
+ value if value && !value.empty?
263
+ end
264
+
265
+ private def upload_date_for(clip : Hash(String, JSON::Any)) : String?
266
+ created_at = JsonValue.string(clip["createdAt"]?)
267
+ return nil unless created_at && created_at.size >= 10
268
+
269
+ date = created_at[0, 10]
270
+ date if /\A\d{4}-\d{2}-\d{2}\z/.matches?(date)
271
+ end
272
+ end
273
+ end