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,180 @@
1
+ require "json"
2
+ require "uri"
3
+
4
+ require "../errors"
5
+ require "../extractor"
6
+ require "../format"
7
+ require "../json_value"
8
+ require "../media"
9
+ require "../timestamp"
10
+
11
+ module Rill::Extractors
12
+ class InternetArchive < Extractor
13
+ BASE_URL = "https://archive.org"
14
+ DETAILS_PATH = %r{\A/details/([^/]+)/?\z}
15
+
16
+ AUDIO_EXTENSIONS = %w(
17
+ aac aif aiff ape flac m4a mp3 oga ogg opus shn wav wma
18
+ )
19
+
20
+ MEDIA_EXTENSIONS = {
21
+ "3gp", "avi", "flv", "m2ts", "mkv", "mov", "mp4", "m4v",
22
+ "mpg", "mpeg", "ogv", "ts", "webm", "wmv",
23
+ "aac", "aif", "aiff", "ape", "flac", "m4a", "mp3", "oga",
24
+ "ogg", "opus", "shn", "wav", "wma",
25
+ }
26
+
27
+ def initialize(@base_url : String = BASE_URL)
28
+ end
29
+
30
+ def name : String
31
+ "internet_archive"
32
+ end
33
+
34
+ def matches?(url : URI) : Bool
35
+ return false unless url.scheme == "http" || url.scheme == "https"
36
+ return false unless archive_host?(url.host)
37
+
38
+ !DETAILS_PATH.match(url.path).nil?
39
+ end
40
+
41
+ def extract(url : URI, client : HTTPClient) : Media
42
+ identifier = identifier_for(url)
43
+ unless identifier
44
+ raise UnsupportedUrlError.new(url.to_s)
45
+ end
46
+
47
+ metadata_url = URI.parse("#{@base_url}/metadata/#{URI.encode_path_segment(identifier)}")
48
+ response = client.get(metadata_url)
49
+ payload = parse_payload(response.body, url)
50
+
51
+ if payload["is_dark"]?.try(&.as_bool?)
52
+ raise MediaUnavailableError.new(url.to_s, "item is dark")
53
+ end
54
+
55
+ metadata = payload["metadata"]?.try(&.as_h?)
56
+ unless metadata
57
+ raise MediaUnavailableError.new(url.to_s, "item metadata is missing")
58
+ end
59
+
60
+ formats = formats_from(payload["files"]?, identifier)
61
+ if formats.empty?
62
+ raise MediaUnavailableError.new(url.to_s, "item has no downloadable media")
63
+ end
64
+
65
+ title = JsonValue.string(metadata["title"]?) || identifier
66
+ duration = Timestamp.clock_seconds(JsonValue.string(metadata["length"]?))
67
+ duration ||= formats.compact_map(&.duration_seconds).first?
68
+
69
+ Media.new(
70
+ id: JsonValue.string(metadata["identifier"]?) || identifier,
71
+ title: title,
72
+ description: JsonValue.string(metadata["description"]?),
73
+ webpage_url: url,
74
+ uploader: JsonValue.string(metadata["creator"]?) || JsonValue.string(metadata["uploader"]?),
75
+ duration_seconds: duration,
76
+ thumbnail_url: thumbnail_url_for(identifier),
77
+ upload_date: date_value(metadata),
78
+ extractor: name,
79
+ formats: formats,
80
+ )
81
+ end
82
+
83
+ private def identifier_for(url : URI) : String?
84
+ match = DETAILS_PATH.match(url.path)
85
+ return nil unless match
86
+
87
+ identifier = URI.decode(match[1])
88
+ identifier.empty? ? nil : identifier
89
+ end
90
+
91
+ private def archive_host?(host : String?) : Bool
92
+ case host.try(&.downcase)
93
+ when "archive.org", "www.archive.org"
94
+ true
95
+ else
96
+ false
97
+ end
98
+ end
99
+
100
+ private def parse_payload(body : String, url : URI) : Hash(String, JSON::Any)
101
+ payload = JSON.parse(body).as_h?
102
+ unless payload
103
+ raise MediaUnavailableError.new(url.to_s, "item metadata is not a JSON object")
104
+ end
105
+
106
+ payload
107
+ rescue JSON::ParseException
108
+ raise MediaUnavailableError.new(url.to_s, "item metadata is not valid JSON")
109
+ end
110
+
111
+ private def formats_from(files_value : JSON::Any?, identifier : String) : Array(Format)
112
+ files = files_value.try(&.as_a?)
113
+ return [] of Format unless files
114
+
115
+ formats = [] of Format
116
+
117
+ files.each do |file|
118
+ file_hash = file.as_h?
119
+ next unless file_hash
120
+
121
+ file_name = file_hash["name"]?.try(&.as_s?)
122
+ next unless file_name
123
+
124
+ extension = File.extname(file_name).lchop('.').downcase
125
+ next unless MEDIA_EXTENSIONS.includes?(extension)
126
+
127
+ label = JsonValue.string(file_hash["format"]?)
128
+ format = Format.new(
129
+ format_id: file_name,
130
+ url: download_url(identifier, file_name),
131
+ kind: kind_for(extension),
132
+ container: extension,
133
+ width: JsonValue.dimension(file_hash["width"]?),
134
+ height: JsonValue.dimension(file_hash["height"]?),
135
+ filesize: int_value(file_hash["size"]?),
136
+ duration_seconds: Timestamp.clock_seconds(JsonValue.string(file_hash["length"]?)),
137
+ note: label,
138
+ )
139
+ formats << format
140
+ end
141
+
142
+ formats
143
+ end
144
+
145
+ private def download_url(identifier : String, file_name : String) : URI
146
+ encoded_name = file_name.split('/').reject(&.empty?).map do |segment|
147
+ URI.encode_path_segment(segment)
148
+ end.join('/')
149
+
150
+ encoded_identifier = URI.encode_path_segment(identifier)
151
+ URI.parse("#{@base_url}/download/#{encoded_identifier}/#{encoded_name}")
152
+ end
153
+
154
+ private def thumbnail_url_for(identifier : String) : URI
155
+ URI.parse("#{@base_url}/services/img/#{URI.encode_path_segment(identifier)}")
156
+ end
157
+
158
+ private def kind_for(extension : String) : FormatKind
159
+ if AUDIO_EXTENSIONS.includes?(extension)
160
+ FormatKind::Audio
161
+ else
162
+ FormatKind::Combined
163
+ end
164
+ end
165
+
166
+ private def date_value(metadata : Hash(String, JSON::Any)) : String?
167
+ value = JsonValue.string(metadata["date"]?) || JsonValue.string(metadata["addeddate"]?)
168
+ return nil unless value
169
+
170
+ value.size >= 10 ? value[0, 10] : value
171
+ end
172
+
173
+ private def int_value(value : JSON::Any?) : Int64?
174
+ parsed = JsonValue.integer(value)
175
+ return nil unless parsed && parsed >= 0
176
+
177
+ parsed
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,81 @@
1
+ require "uri"
2
+
3
+ require "../errors"
4
+ require "../extractor"
5
+ require "../http_url"
6
+ require "../media"
7
+ require "../playlist"
8
+
9
+ module Rill::Extractors
10
+ class M3u < Extractor
11
+ EXTINF = "#EXTINF:"
12
+
13
+ def name : String
14
+ "m3u"
15
+ end
16
+
17
+ def matches?(url : URI) : Bool
18
+ return false unless url.scheme == "http" || url.scheme == "https"
19
+
20
+ File.extname(url.path).downcase == ".m3u"
21
+ end
22
+
23
+ def extract(url : URI, client : HTTPClient) : Playlist
24
+ response = client.get(url)
25
+
26
+ if response.body.includes?("#EXT-X-")
27
+ raise UnsupportedManifestError.new("HLS playlists must use a .m3u8 URL")
28
+ end
29
+
30
+ entries = parse_entries(response.body, response.url)
31
+ if entries.empty?
32
+ raise MediaUnavailableError.new(url.to_s, "playlist has no entries")
33
+ end
34
+
35
+ Playlist.new(
36
+ title: title_for(url),
37
+ webpage_url: url,
38
+ extractor: name,
39
+ entries: entries,
40
+ )
41
+ end
42
+
43
+ private def parse_entries(body : String, base_url : URI) : Array(PlaylistEntry)
44
+ entries = [] of PlaylistEntry
45
+ pending_title : String? = nil
46
+
47
+ body.lchop('\uFEFF').each_line do |raw|
48
+ line = raw.strip
49
+ next if line.empty?
50
+
51
+ if line.starts_with?(EXTINF)
52
+ pending_title = parse_title(line[EXTINF.size..])
53
+ elsif line.starts_with?('#')
54
+ next
55
+ else
56
+ if entry_url = HttpUrl.resolve(base_url, line)
57
+ entries << PlaylistEntry.new(url: entry_url, title: pending_title)
58
+ end
59
+ pending_title = nil
60
+ end
61
+ end
62
+
63
+ entries
64
+ end
65
+
66
+ private def parse_title(value : String) : String?
67
+ _, separator, title = value.partition(',')
68
+ return nil if separator.empty?
69
+
70
+ title = title.strip
71
+ title.empty? ? nil : title
72
+ end
73
+
74
+ private def title_for(url : URI) : String
75
+ basename = File.basename(url.path)
76
+ stem = File.basename(basename, File.extname(basename))
77
+ decoded = URI.decode(stem)
78
+ decoded.empty? ? "playlist" : decoded
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,219 @@
1
+ require "json"
2
+ require "uri"
3
+
4
+ require "../errors"
5
+ require "../extractor"
6
+ require "../format"
7
+ require "../http_url"
8
+ require "../json_value"
9
+ require "../media"
10
+ require "../mime_type"
11
+
12
+ module Rill::Extractors
13
+ class MediaCccDe < Extractor
14
+ DEFAULT_API_BASE = "https://media.ccc.de"
15
+ EVENT_PATH = %r{\A/v/([^/]+)/?\z}
16
+ CONFERENCE_PATH = %r{\A/(?:c|conferences)/([^/]+)/?\z}
17
+
18
+ def initialize(@api_base : String = DEFAULT_API_BASE)
19
+ end
20
+
21
+ def name : String
22
+ "media_ccc_de"
23
+ end
24
+
25
+ def matches?(url : URI) : Bool
26
+ return false unless url.scheme == "http" || url.scheme == "https"
27
+ return false unless host?(url.host)
28
+
29
+ path = url.path
30
+ !EVENT_PATH.match(path).nil? || !CONFERENCE_PATH.match(path).nil?
31
+ end
32
+
33
+ def extract(url : URI, client : HTTPClient) : Media | Playlist
34
+ if slug = slug_for(url, EVENT_PATH)
35
+ extract_event(url, client, slug)
36
+ elsif acronym = slug_for(url, CONFERENCE_PATH)
37
+ extract_conference(url, client, acronym)
38
+ else
39
+ raise UnsupportedUrlError.new(url.to_s)
40
+ end
41
+ end
42
+
43
+ private def extract_event(url : URI, client : HTTPClient, slug : String) : Media
44
+ api_url = URI.parse("#{@api_base}/public/events/#{URI.encode_path_segment(slug)}")
45
+ payload = api_payload(client, api_url, url, "event")
46
+
47
+ formats = formats_from(payload["recordings"]?)
48
+ if formats.empty?
49
+ raise MediaUnavailableError.new(url.to_s, "event has no downloadable recordings")
50
+ end
51
+
52
+ Media.new(
53
+ id: JsonValue.string(payload["guid"]?),
54
+ title: JsonValue.string(payload["title"]?) || slug,
55
+ description: JsonValue.string(payload["description"]?),
56
+ webpage_url: url,
57
+ uploader: persons(payload["persons"]?),
58
+ duration_seconds: JsonValue.seconds(payload["duration"]?),
59
+ thumbnail_url: thumbnail_url(payload),
60
+ upload_date: date_value(payload),
61
+ extractor: name,
62
+ formats: formats,
63
+ )
64
+ end
65
+
66
+ private def extract_conference(
67
+ url : URI,
68
+ client : HTTPClient,
69
+ acronym : String,
70
+ ) : Playlist
71
+ api_url = URI.parse("#{@api_base}/public/conferences/#{URI.encode_path_segment(acronym)}")
72
+ payload = api_payload(client, api_url, url, "conference")
73
+
74
+ entries = [] of PlaylistEntry
75
+ events = payload["events"]?.try(&.as_a?) || [] of JSON::Any
76
+
77
+ events.each do |event|
78
+ event_hash = event.as_h?
79
+ next unless event_hash
80
+
81
+ entry_url = event_url(event_hash)
82
+ next unless entry_url
83
+
84
+ entries << PlaylistEntry.new(
85
+ url: entry_url,
86
+ title: JsonValue.string(event_hash["title"]?),
87
+ id: JsonValue.string(event_hash["guid"]?),
88
+ )
89
+ end
90
+
91
+ if entries.empty?
92
+ raise MediaUnavailableError.new(url.to_s, "conference has no events")
93
+ end
94
+
95
+ Playlist.new(
96
+ id: acronym,
97
+ title: JsonValue.string(payload["title"]?) || acronym,
98
+ webpage_url: url,
99
+ extractor: name,
100
+ entries: entries,
101
+ )
102
+ end
103
+
104
+ private def api_payload(
105
+ client : HTTPClient,
106
+ api_url : URI,
107
+ url : URI,
108
+ subject : String,
109
+ ) : Hash(String, JSON::Any)
110
+ response = client.get(api_url)
111
+ parse_payload(response.body, url)
112
+ rescue ex : HTTPError
113
+ if ex.status.code == 404
114
+ raise MediaUnavailableError.new(url.to_s, "#{subject} not found")
115
+ end
116
+ raise ex
117
+ end
118
+
119
+ private def host?(host : String?) : Bool
120
+ case host.try(&.downcase)
121
+ when "media.ccc.de", "www.media.ccc.de"
122
+ true
123
+ else
124
+ false
125
+ end
126
+ end
127
+
128
+ private def slug_for(url : URI, pattern : Regex) : String?
129
+ match = pattern.match(url.path)
130
+ return nil unless match
131
+
132
+ slug = URI.decode(match[1])
133
+ slug.empty? ? nil : slug
134
+ end
135
+
136
+ private def parse_payload(body : String, url : URI) : Hash(String, JSON::Any)
137
+ payload = JSON.parse(body).as_h?
138
+ unless payload
139
+ raise MediaUnavailableError.new(url.to_s, "metadata is not a JSON object")
140
+ end
141
+
142
+ payload
143
+ rescue JSON::ParseException
144
+ raise MediaUnavailableError.new(url.to_s, "metadata is not valid JSON")
145
+ end
146
+
147
+ private def formats_from(recordings_value : JSON::Any?) : Array(Format)
148
+ recordings = recordings_value.try(&.as_a?)
149
+ return [] of Format unless recordings
150
+
151
+ formats = [] of Format
152
+
153
+ recordings.each do |recording|
154
+ recording_hash = recording.as_h?
155
+ next unless recording_hash
156
+
157
+ filename = JsonValue.string(recording_hash["filename"]?)
158
+ next unless filename
159
+
160
+ recording_url = HttpUrl.absolute(JsonValue.string(recording_hash["recording_url"]?))
161
+ next unless recording_url
162
+
163
+ mime_type = JsonValue.string(recording_hash["mime_type"]?) || ""
164
+ mime = MimeType.parse(mime_type)
165
+ next if mime.empty?
166
+
167
+ audio = mime.audio?
168
+ formats << Format.new(
169
+ format_id: filename,
170
+ url: recording_url,
171
+ kind: audio ? FormatKind::Audio : FormatKind::Combined,
172
+ container: mime.container,
173
+ video_codec: audio ? nil : mime.codecs,
174
+ audio_codec: audio ? mime.audio_codec : nil,
175
+ width: JsonValue.dimension(recording_hash["width"]?),
176
+ height: JsonValue.dimension(recording_hash["height"]?),
177
+ language: JsonValue.string(recording_hash["language"]?),
178
+ note: mime_type,
179
+ )
180
+ end
181
+
182
+ formats
183
+ end
184
+
185
+ private def persons(value : JSON::Any?) : String?
186
+ array = value.try(&.as_a?)
187
+ return nil unless array
188
+
189
+ names = array.compact_map(&.as_s?)
190
+ names.empty? ? nil : names.join(", ")
191
+ end
192
+
193
+ private def event_url(event : Hash(String, JSON::Any)) : URI?
194
+ value = JsonValue.string(event["frontend_link"]?) || JsonValue.string(event["link"]?)
195
+
196
+ if value.nil?
197
+ slug = JsonValue.string(event["slug"]?)
198
+ value = "#{DEFAULT_API_BASE}/v/#{URI.encode_path_segment(slug)}" if slug
199
+ end
200
+
201
+ return nil unless value
202
+ value = value.sub("/public/events/", "/v/")
203
+ HttpUrl.absolute(value)
204
+ end
205
+
206
+ private def thumbnail_url(payload : Hash(String, JSON::Any)) : URI?
207
+ value = JsonValue.string(payload["thumb_url"]?) || JsonValue.string(payload["poster_url"]?)
208
+ value ? HttpUrl.absolute(value) : nil
209
+ end
210
+
211
+ private def date_value(payload : Hash(String, JSON::Any)) : String?
212
+ value = JsonValue.string(payload["date"]?)
213
+ return nil unless value
214
+ return nil if value.size < 10
215
+
216
+ value[0, 10]
217
+ end
218
+ end
219
+ end