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.
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,155 @@
1
+ require "http/status"
2
+ require "uri"
3
+
4
+ module Rill
5
+ class Error < Exception
6
+ end
7
+
8
+ class UnsupportedUrlError < Error
9
+ getter url : String
10
+
11
+ def initialize(@url : String)
12
+ super("unsupported URL: #{@url}")
13
+ end
14
+ end
15
+
16
+ class ExtractionError < Error
17
+ end
18
+
19
+ class MediaUnavailableError < ExtractionError
20
+ getter url : String
21
+
22
+ def initialize(@url : String, reason : String)
23
+ super("media unavailable at #{@url}: #{reason}")
24
+ end
25
+ end
26
+
27
+ class InvalidExtractionError < ExtractionError
28
+ getter url : String
29
+
30
+ def initialize(@url : String, reason : String)
31
+ super("invalid extraction result for #{@url}: #{reason}")
32
+ end
33
+ end
34
+
35
+ class HTTPError < Error
36
+ getter url : URI
37
+ getter status : HTTP::Status
38
+ getter response_body : String
39
+
40
+ def initialize(@url : URI, @status : HTTP::Status, @response_body : String)
41
+ super("GET #{@url} returned #{@status.code} #{@status.description}")
42
+ end
43
+ end
44
+
45
+ class ResponseTooLargeError < Error
46
+ getter url : URI
47
+ getter limit : Int64
48
+
49
+ def initialize(@url : URI, @limit : Int64)
50
+ super("response from #{@url} exceeded #{@limit} bytes")
51
+ end
52
+ end
53
+
54
+ class TooManyRedirectsError < Error
55
+ getter url : URI
56
+ getter limit : Int32
57
+
58
+ def initialize(@url : URI, @limit : Int32)
59
+ super("request to #{@url} exceeded #{@limit} redirects")
60
+ end
61
+ end
62
+
63
+ class IncompleteResponseError < Error
64
+ getter url : URI
65
+ getter expected_bytes : Int64
66
+ getter actual_bytes : Int64
67
+
68
+ def initialize(@url : URI, @expected_bytes : Int64, @actual_bytes : Int64)
69
+ super("response from #{@url} ended after #{@actual_bytes} of #{@expected_bytes} bytes")
70
+ end
71
+ end
72
+
73
+ class NetworkError < Error
74
+ getter url : URI
75
+
76
+ def initialize(@url : URI, cause : Exception)
77
+ super("request to #{@url} failed: #{cause.message}", cause)
78
+ end
79
+ end
80
+
81
+ class InvalidUrlError < Error
82
+ getter url : String
83
+
84
+ def initialize(@url : String, reason : String)
85
+ super("invalid URL #{@url}: #{reason}")
86
+ end
87
+ end
88
+
89
+ class InvalidOptionsError < Error
90
+ def initialize(reason : String)
91
+ super("invalid request options: #{reason}")
92
+ end
93
+ end
94
+
95
+ class MediaBackendUnavailableError < Error
96
+ end
97
+
98
+ class MediaMuxError < Error
99
+ end
100
+
101
+ class ManifestError < Error
102
+ end
103
+
104
+ class UnsupportedManifestError < ManifestError
105
+ end
106
+
107
+ class CancelledError < Error
108
+ def initialize
109
+ super("operation cancelled")
110
+ end
111
+ end
112
+
113
+ class DownloadError < Error
114
+ end
115
+
116
+ class DownloadWriteError < DownloadError
117
+ getter path : String
118
+
119
+ def initialize(@path : String, cause : Exception)
120
+ super("failed to write #{@path}: #{cause.message}", cause)
121
+ end
122
+ end
123
+
124
+ class FileExistsError < DownloadError
125
+ getter path : String
126
+
127
+ def initialize(@path : String)
128
+ super("file already exists: #{@path}")
129
+ end
130
+ end
131
+
132
+ class IncompleteDownloadError < DownloadError
133
+ getter path : String
134
+ getter expected_bytes : Int64
135
+ getter actual_bytes : Int64
136
+
137
+ def initialize(@path : String, @expected_bytes : Int64, @actual_bytes : Int64)
138
+ super("incomplete download for #{@path}: expected #{@expected_bytes} bytes, got #{@actual_bytes}")
139
+ end
140
+ end
141
+
142
+ class ResumeError < DownloadError
143
+ getter path : String
144
+
145
+ def initialize(@path : String, reason : String)
146
+ super("cannot resume #{@path}: #{reason}")
147
+ end
148
+ end
149
+
150
+ class InvalidOutputPathError < DownloadError
151
+ def initialize(path : String, directory : String)
152
+ super("refusing to write #{path} outside #{directory}")
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,18 @@
1
+ require "json"
2
+ require "uri"
3
+
4
+ require "./errors"
5
+ require "./format"
6
+ require "./http_client"
7
+ require "./media"
8
+ require "./playlist"
9
+
10
+ module Rill
11
+ abstract class Extractor
12
+ abstract def name : String
13
+
14
+ abstract def matches?(url : URI) : Bool
15
+
16
+ abstract def extract(url : URI, client : HTTPClient) : Media | Playlist
17
+ end
18
+ end
@@ -0,0 +1,98 @@
1
+ require "uri"
2
+
3
+ require "./errors"
4
+ require "./extractor"
5
+ require "./extractors/bandcamp"
6
+ require "./extractors/dash"
7
+ require "./extractors/direct_media"
8
+ require "./extractors/generic"
9
+ require "./extractors/hls"
10
+ require "./extractors/internet_archive"
11
+ require "./extractors/m3u"
12
+ require "./extractors/media_ccc_de"
13
+ require "./extractors/mixcloud"
14
+ require "./extractors/peer_tube"
15
+ require "./extractors/rumble"
16
+ require "./extractors/soundcloud"
17
+ require "./extractors/spotify"
18
+ require "./extractors/twitch_clip"
19
+ require "./extractors/youtube"
20
+ require "./http_client"
21
+ require "./playlist"
22
+ require "./request_options"
23
+
24
+ module Rill
25
+ class ExtractorRegistry
26
+ def self.default : self
27
+ registry = new
28
+ registry.register(Extractors::InternetArchive)
29
+ registry.register(Extractors::MediaCccDe)
30
+ registry.register(Extractors::PeerTube)
31
+ registry.register(Extractors::YouTube)
32
+ registry.register(Extractors::Spotify)
33
+ registry.register(Extractors::Bandcamp)
34
+ registry.register(Extractors::Rumble)
35
+ registry.register(Extractors::SoundCloud)
36
+ registry.register(Extractors::Mixcloud)
37
+ registry.register(Extractors::TwitchClip)
38
+ registry.register(Extractors::Hls)
39
+ registry.register(Extractors::Dash)
40
+ registry.register(Extractors::M3u)
41
+ registry.register(Extractors::DirectMedia)
42
+ registry.register(Extractors::Generic)
43
+ registry
44
+ end
45
+
46
+ def initialize
47
+ @extractors = [] of Extractor.class
48
+ end
49
+
50
+ def register(extractor : Extractor.class) : Nil
51
+ @extractors << extractor
52
+ end
53
+
54
+ def extractors : Array(Extractor.class)
55
+ @extractors.dup
56
+ end
57
+
58
+ def extractor_for(url : String | URI) : Extractor.class | Nil
59
+ uri = url.is_a?(URI) ? url : URI.parse(url)
60
+ return nil unless uri.scheme == "http" || uri.scheme == "https"
61
+
62
+ @extractors.find { |extractor| extractor.new.matches?(uri) }
63
+ rescue URI::Error
64
+ nil
65
+ end
66
+
67
+ def extract(url : String | URI, options : RequestOptions = RequestOptions.new) : Media | Playlist
68
+ extract(url, HTTPClient.new(options))
69
+ end
70
+
71
+ def extract(url : String | URI, client : HTTPClient) : Media | Playlist
72
+ uri = parse_url(url)
73
+ extractor = extractor_for(uri)
74
+ unless extractor
75
+ raise UnsupportedUrlError.new(uri.to_s)
76
+ end
77
+
78
+ result = extractor.new.extract(uri, client)
79
+ case result
80
+ when Media
81
+ result.validate!
82
+ when Playlist
83
+ result.validate!
84
+ end
85
+ result
86
+ end
87
+
88
+ private def parse_url(url : String | URI) : URI
89
+ uri = url.is_a?(URI) ? url : URI.parse(url)
90
+ unless uri.scheme == "http" || uri.scheme == "https"
91
+ raise UnsupportedUrlError.new(uri.to_s)
92
+ end
93
+ uri
94
+ rescue URI::Error
95
+ raise UnsupportedUrlError.new(url.to_s)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,265 @@
1
+ require "html"
2
+ require "json"
3
+ require "time"
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
+ require "../playlist"
13
+
14
+ module Rill::Extractors
15
+ class Bandcamp < Extractor
16
+ HOST = /\A[a-z0-9-]+\.bandcamp\.com\z/i
17
+ PATH = %r{\A/(track|album)/([^/]+)/?\z}
18
+ ISO_DATE = /\A\d{4}-\d{2}-\d{2}/
19
+ DATE = "%d %b %Y %H:%M:%S GMT"
20
+ TRALBUM = /data-tralbum\s*=\s*(?:"([^"]*)"|'([^']*)')/m
21
+ OG_IMAGE = /\bproperty\s*=\s*(?:"og:image"|'og:image')/i
22
+ CONTENT = /\bcontent\s*=\s*(?:"([^"]*)"|'([^']*)')/i
23
+
24
+ def name : String
25
+ "bandcamp"
26
+ end
27
+
28
+ def matches?(url : URI) : Bool
29
+ return false unless http_url?(url)
30
+ return false unless artist_host?(url.host)
31
+
32
+ !PATH.match(url.path).nil?
33
+ end
34
+
35
+ def extract(url : URI, client : HTTPClient) : Media | Playlist
36
+ match = PATH.match(url.path)
37
+ unless match
38
+ raise UnsupportedUrlError.new(url.to_s)
39
+ end
40
+
41
+ response = fetch_page(client, url)
42
+ tralbum = tralbum_data(response.body, url)
43
+
44
+ if match[1] == "track"
45
+ extract_track(url, tralbum, thumbnail_url(response.body, response.url))
46
+ else
47
+ extract_album(url, tralbum)
48
+ end
49
+ end
50
+
51
+ private def http_url?(url : URI) : Bool
52
+ url.scheme == "http" || url.scheme == "https"
53
+ end
54
+
55
+ private def artist_host?(host : String?) : Bool
56
+ return false unless host
57
+
58
+ name = host.downcase
59
+ return false if name == "www.bandcamp.com"
60
+
61
+ !HOST.match(name).nil?
62
+ end
63
+
64
+ private def fetch_page(client : HTTPClient, url : URI) : HTTPClient::Response
65
+ client.get(url)
66
+ rescue ex : HTTPError
67
+ if ex.status.code == 404
68
+ raise MediaUnavailableError.new(url.to_s, "page not found")
69
+ end
70
+
71
+ raise ex
72
+ end
73
+
74
+ private def tralbum_data(body : String, url : URI) : Hash(String, JSON::Any)
75
+ attribute = tralbum_attribute(body)
76
+ unless attribute
77
+ raise MediaUnavailableError.new(url.to_s, "page has no data-tralbum attribute")
78
+ end
79
+
80
+ payload = JSON.parse(HTML.unescape(attribute)).as_h?
81
+ unless payload
82
+ raise MediaUnavailableError.new(url.to_s, "data-tralbum is not a JSON object")
83
+ end
84
+
85
+ payload
86
+ rescue JSON::ParseException
87
+ raise MediaUnavailableError.new(url.to_s, "data-tralbum is not valid JSON")
88
+ end
89
+
90
+ private def tralbum_attribute(body : String) : String?
91
+ match = TRALBUM.match(body)
92
+ return nil unless match
93
+
94
+ match[1]? || match[2]?
95
+ end
96
+
97
+ private def extract_track(
98
+ url : URI,
99
+ tralbum : Hash(String, JSON::Any),
100
+ thumbnail : URI?,
101
+ ) : Media
102
+ if tralbum["is_private_stream"]?.try(&.as_bool?)
103
+ raise MediaUnavailableError.new(url.to_s, "track stream is private")
104
+ end
105
+
106
+ current = tralbum["current"]?.try(&.as_h?) || {} of String => JSON::Any
107
+ track = first_track(tralbum, url)
108
+
109
+ if track["private"]?.try(&.as_bool?)
110
+ raise MediaUnavailableError.new(url.to_s, "track is private")
111
+ end
112
+
113
+ title = JsonValue.string(current["title"]?) || JsonValue.string(track["title"]?)
114
+ unless title
115
+ raise MediaUnavailableError.new(url.to_s, "track title is missing")
116
+ end
117
+
118
+ duration = JsonValue.seconds(track["duration"]?)
119
+ format = audio_format(track, url, duration)
120
+
121
+ Media.new(
122
+ id: track_id(track) || JsonValue.string(tralbum["id"]?),
123
+ title: title,
124
+ description: JsonValue.string(current["about"]?),
125
+ webpage_url: url,
126
+ uploader: JsonValue.string(tralbum["artist"]?) || JsonValue.string(current["artist"]?),
127
+ duration_seconds: duration,
128
+ thumbnail_url: thumbnail,
129
+ upload_date: upload_date(current),
130
+ extractor: name,
131
+ formats: [format],
132
+ )
133
+ end
134
+
135
+ private def first_track(
136
+ tralbum : Hash(String, JSON::Any),
137
+ url : URI,
138
+ ) : Hash(String, JSON::Any)
139
+ tracks = tralbum["trackinfo"]?.try(&.as_a?)
140
+ unless tracks
141
+ raise MediaUnavailableError.new(url.to_s, "page has no trackinfo")
142
+ end
143
+
144
+ track = tracks.first?.try(&.as_h?)
145
+ unless track
146
+ raise MediaUnavailableError.new(url.to_s, "track data is missing")
147
+ end
148
+
149
+ track
150
+ end
151
+
152
+ private def audio_format(
153
+ track : Hash(String, JSON::Any),
154
+ url : URI,
155
+ duration : Float64?,
156
+ ) : Format
157
+ file = track["file"]?.try(&.as_h?)
158
+ value = file.try { |hash| JsonValue.string(hash["mp3-128"]?) }
159
+ unless value
160
+ raise MediaUnavailableError.new(url.to_s, "stream is unavailable")
161
+ end
162
+
163
+ stream_url = HttpUrl.absolute(protocol_url(value))
164
+ unless stream_url
165
+ raise MediaUnavailableError.new(url.to_s, "stream URL is invalid")
166
+ end
167
+
168
+ Format.new(
169
+ format_id: "mp3-128",
170
+ url: stream_url,
171
+ kind: FormatKind::Audio,
172
+ container: "mp3",
173
+ bitrate: 128_000_i64,
174
+ duration_seconds: duration,
175
+ )
176
+ end
177
+
178
+ private def protocol_url(value : String) : String
179
+ value.starts_with?("//") ? "https:#{value}" : value
180
+ end
181
+
182
+ private def extract_album(
183
+ page_url : URI,
184
+ tralbum : Hash(String, JSON::Any),
185
+ ) : Playlist
186
+ current = tralbum["current"]?.try(&.as_h?) || {} of String => JSON::Any
187
+ title = JsonValue.string(current["title"]?)
188
+ unless title
189
+ raise MediaUnavailableError.new(page_url.to_s, "album title is missing")
190
+ end
191
+
192
+ entries = album_entries(tralbum["trackinfo"]?, page_url)
193
+ if entries.empty?
194
+ raise MediaUnavailableError.new(page_url.to_s, "album has no tracks")
195
+ end
196
+
197
+ Playlist.new(
198
+ id: JsonValue.string(current["id"]?),
199
+ title: title,
200
+ webpage_url: page_url,
201
+ extractor: name,
202
+ entries: entries,
203
+ )
204
+ end
205
+
206
+ private def album_entries(
207
+ value : JSON::Any?,
208
+ page_url : URI,
209
+ ) : Array(PlaylistEntry)
210
+ tracks = value.try(&.as_a?)
211
+ return [] of PlaylistEntry unless tracks
212
+
213
+ entries = [] of PlaylistEntry
214
+ tracks.each do |item|
215
+ track = item.as_h?
216
+ next unless track
217
+
218
+ title_link = JsonValue.string(track["title_link"]?)
219
+ next unless title_link
220
+
221
+ entry_url = HttpUrl.resolve(page_url, title_link)
222
+ next unless entry_url
223
+
224
+ entries << PlaylistEntry.new(
225
+ url: entry_url,
226
+ title: JsonValue.string(track["title"]?),
227
+ id: track_id(track),
228
+ )
229
+ end
230
+
231
+ entries
232
+ end
233
+
234
+ private def track_id(track : Hash(String, JSON::Any)) : String?
235
+ JsonValue.string(track["id"]?) || JsonValue.string(track["track_id"]?)
236
+ end
237
+
238
+ private def upload_date(current : Hash(String, JSON::Any)) : String?
239
+ value = JsonValue.string(current["release_date"]?) || JsonValue.string(current["publish_date"]?)
240
+ return nil unless value
241
+ return value[0, 10] if ISO_DATE.match(value)
242
+
243
+ Time.parse(value, DATE, Time::Location::UTC).to_s("%Y-%m-%d")
244
+ rescue Time::Format::Error
245
+ nil
246
+ end
247
+
248
+ private def thumbnail_url(body : String, base_url : URI) : URI?
249
+ body.scan(/<meta\b[^>]*>/i) do |match|
250
+ meta = match[0]
251
+ next unless OG_IMAGE.match(meta)
252
+
253
+ content = CONTENT.match(meta)
254
+ next unless content
255
+
256
+ value = content[1]? || content[2]?
257
+ next unless value
258
+
259
+ return HttpUrl.resolve(base_url, HTML.unescape(value))
260
+ end
261
+
262
+ nil
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,77 @@
1
+ require "uri"
2
+
3
+ require "../dash/parser"
4
+ require "../errors"
5
+ require "../extractor"
6
+ require "../format"
7
+ require "../media"
8
+
9
+ module Rill::Extractors
10
+ class Dash < Extractor
11
+ def name : String
12
+ "dash"
13
+ end
14
+
15
+ def matches?(url : URI) : Bool
16
+ return false unless url.scheme == "http" || url.scheme == "https"
17
+
18
+ File.extname(url.path).downcase == ".mpd"
19
+ end
20
+
21
+ def extract(url : URI, client : HTTPClient) : Media
22
+ response = client.get(url)
23
+ manifest = Rill::Dash::Parser.parse(response.body, response.url)
24
+
25
+ formats = [] of Format
26
+ unsupported = false
27
+
28
+ manifest.representations.each do |representation|
29
+ unless representation.supported?
30
+ unsupported = true
31
+ next
32
+ end
33
+
34
+ mime = representation.mime
35
+ next unless mime
36
+
37
+ audio = mime.audio?
38
+ formats << Format.new(
39
+ format_id: representation.reference,
40
+ url: response.url,
41
+ kind: audio ? FormatKind::Audio : FormatKind::Video,
42
+ protocol: FormatProtocol::Dash,
43
+ container: mime.container,
44
+ bitrate: representation.bandwidth,
45
+ video_codec: audio ? nil : (representation.codecs || mime.codecs),
46
+ audio_codec: audio ? (representation.codecs || mime.audio_codec) : nil,
47
+ width: representation.width,
48
+ height: representation.height,
49
+ fps: representation.fps,
50
+ duration_seconds: representation.duration_seconds,
51
+ language: representation.language,
52
+ note: representation.codecs || "DASH",
53
+ )
54
+ end
55
+
56
+ if formats.empty?
57
+ reason = unsupported ? "DASH representations use unsupported segment information" : "DASH manifest has no representations"
58
+ raise MediaUnavailableError.new(url.to_s, reason)
59
+ end
60
+
61
+ Media.new(
62
+ title: title_for(url),
63
+ webpage_url: url,
64
+ duration_seconds: manifest.duration_seconds,
65
+ extractor: name,
66
+ formats: formats,
67
+ )
68
+ end
69
+
70
+ private def title_for(url : URI) : String
71
+ basename = File.basename(url.path)
72
+ stem = File.basename(basename, File.extname(basename))
73
+ decoded = URI.decode(stem)
74
+ decoded.empty? ? "manifest" : decoded
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,73 @@
1
+ require "uri"
2
+
3
+ require "../extractor"
4
+ require "../format"
5
+ require "../media"
6
+
7
+ module Rill::Extractors
8
+ class DirectMedia < Extractor
9
+ VIDEO_EXTENSIONS = %w(
10
+ 3gp avi flv m2ts mkv mov mp4 m4v mpg mpeg ogv ts webm wmv
11
+ )
12
+
13
+ AUDIO_EXTENSIONS = %w(
14
+ aac aif aiff ape flac m4a mp3 oga ogg opus shn wav wma
15
+ )
16
+
17
+ def name : String
18
+ "direct"
19
+ end
20
+
21
+ def matches?(url : URI) : Bool
22
+ extension = extension_for(url)
23
+ !extension.nil?
24
+ end
25
+
26
+ def extract(url : URI, client : HTTPClient) : Media
27
+ extension = extension_for(url)
28
+ unless extension
29
+ raise UnsupportedUrlError.new(url.to_s)
30
+ end
31
+
32
+ format = Format.new(
33
+ format_id: "direct",
34
+ url: url,
35
+ kind: kind_for(extension),
36
+ container: extension,
37
+ )
38
+
39
+ Media.new(
40
+ title: title_for(url),
41
+ webpage_url: url,
42
+ extractor: name,
43
+ formats: [format],
44
+ )
45
+ end
46
+
47
+ private def extension_for(url : URI) : String?
48
+ return nil unless url.scheme == "http" || url.scheme == "https"
49
+
50
+ extension = File.extname(url.path).lchop('.').downcase
51
+ return nil if extension.empty?
52
+
53
+ if VIDEO_EXTENSIONS.includes?(extension) || AUDIO_EXTENSIONS.includes?(extension)
54
+ extension
55
+ end
56
+ end
57
+
58
+ private def kind_for(extension : String) : FormatKind
59
+ if AUDIO_EXTENSIONS.includes?(extension)
60
+ FormatKind::Audio
61
+ else
62
+ FormatKind::Combined
63
+ end
64
+ end
65
+
66
+ private def title_for(url : URI) : String
67
+ basename = File.basename(url.path)
68
+ stem = File.basename(basename, File.extname(basename))
69
+ decoded = URI.decode(stem)
70
+ decoded.empty? ? "media" : decoded
71
+ end
72
+ end
73
+ end