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.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +64 -2
- package/binding.gyp +31 -0
- package/index.d.ts +45 -0
- package/index.js +112 -0
- package/native/darwin-arm64/librill.dylib +0 -0
- package/native/darwin-arm64/rill-media +0 -0
- package/native/darwin-arm64/rill_node.node +0 -0
- package/native/darwin-x64/librill.dylib +0 -0
- package/native/darwin-x64/rill-media +0 -0
- package/native/darwin-x64/rill_node.node +0 -0
- package/native/linux-arm64-gnu/librill.so +0 -0
- package/native/linux-arm64-gnu/rill-media +0 -0
- package/native/linux-arm64-gnu/rill_node.node +0 -0
- package/native/linux-x64-gnu/librill.so +0 -0
- package/native/linux-x64-gnu/rill-media +0 -0
- package/native/linux-x64-gnu/rill_node.node +0 -0
- package/native-src/include/rill.h +90 -0
- package/native-src/include/rill_media.h +22 -0
- package/native-src/shard.yml +9 -0
- package/native-src/src/c_api.cr +194 -0
- package/native-src/src/cli.cr +637 -0
- package/native-src/src/rill/c_api/job.cr +336 -0
- package/native-src/src/rill/c_api/memory.cr +32 -0
- package/native-src/src/rill/c_api/status.cr +25 -0
- package/native-src/src/rill/c_api/sync.cr +125 -0
- package/native-src/src/rill/c_api/worker_pool.cr +403 -0
- package/native-src/src/rill/cancellation.cr +29 -0
- package/native-src/src/rill/client.cr +52 -0
- package/native-src/src/rill/cookie_jar.cr +130 -0
- package/native-src/src/rill/dash/manifest.cr +279 -0
- package/native-src/src/rill/dash/parser.cr +227 -0
- package/native-src/src/rill/download_options.cr +96 -0
- package/native-src/src/rill/download_plan.cr +27 -0
- package/native-src/src/rill/download_progress.cr +16 -0
- package/native-src/src/rill/downloader.cr +662 -0
- package/native-src/src/rill/errors.cr +155 -0
- package/native-src/src/rill/extractor.cr +18 -0
- package/native-src/src/rill/extractor_registry.cr +98 -0
- package/native-src/src/rill/extractors/bandcamp.cr +265 -0
- package/native-src/src/rill/extractors/dash.cr +77 -0
- package/native-src/src/rill/extractors/direct_media.cr +73 -0
- package/native-src/src/rill/extractors/generic.cr +441 -0
- package/native-src/src/rill/extractors/hls.cr +202 -0
- package/native-src/src/rill/extractors/internet_archive.cr +180 -0
- package/native-src/src/rill/extractors/m3u.cr +81 -0
- package/native-src/src/rill/extractors/media_ccc_de.cr +219 -0
- package/native-src/src/rill/extractors/mixcloud.cr +495 -0
- package/native-src/src/rill/extractors/peer_tube.cr +226 -0
- package/native-src/src/rill/extractors/rumble.cr +325 -0
- package/native-src/src/rill/extractors/soundcloud.cr +553 -0
- package/native-src/src/rill/extractors/spotify.cr +184 -0
- package/native-src/src/rill/extractors/twitch_clip.cr +273 -0
- package/native-src/src/rill/extractors/youtube.cr +520 -0
- package/native-src/src/rill/format.cr +82 -0
- package/native-src/src/rill/format_selector.cr +143 -0
- package/native-src/src/rill/hls/parser.cr +257 -0
- package/native-src/src/rill/hls/playlist.cr +100 -0
- package/native-src/src/rill/http_client.cr +503 -0
- package/native-src/src/rill/http_url.cr +28 -0
- package/native-src/src/rill/json_value.cr +66 -0
- package/native-src/src/rill/media.cr +151 -0
- package/native-src/src/rill/media_muxer.cr +333 -0
- package/native-src/src/rill/mime_type.cr +58 -0
- package/native-src/src/rill/output_file_name.cr +91 -0
- package/native-src/src/rill/playlist.cr +109 -0
- package/native-src/src/rill/request_options.cr +179 -0
- package/native-src/src/rill/segment_assembly.cr +70 -0
- package/native-src/src/rill/timestamp.cr +34 -0
- package/native-src/src/rill/version.cr +3 -0
- package/native-src/src/rill.cr +47 -0
- package/package.json +48 -4
- package/scripts/build-native.js +46 -0
- package/scripts/install-gyp.js +10 -0
- package/scripts/install.js +19 -0
- package/scripts/lib.js +1 -0
- package/scripts/libdir.js +23 -0
- package/scripts/package-native.js +35 -0
- package/scripts/platform.js +41 -0
- package/scripts/prepack.js +47 -0
- package/src/addon.c +498 -0
|
@@ -0,0 +1,520 @@
|
|
|
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
|
+
require "../mime_type"
|
|
13
|
+
require "../timestamp"
|
|
14
|
+
|
|
15
|
+
module Rill::Extractors
|
|
16
|
+
class YouTube < Extractor
|
|
17
|
+
DEFAULT_API_BASE = "https://www.youtube.com"
|
|
18
|
+
API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
|
|
19
|
+
# NOTE: full browser user agents make YouTube exceed Crystal's 16 KB header limit.
|
|
20
|
+
WATCH_PAGE_USER_AGENT = "Mozilla/5.0"
|
|
21
|
+
VISIONOS_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_3) " \
|
|
22
|
+
"AppleWebKit/605.1.15 (KHTML, like Gecko) " \
|
|
23
|
+
"Version/26.0 Safari/605.1.15"
|
|
24
|
+
VIDEO_ID = /\A[A-Za-z0-9_-]{11}\z/
|
|
25
|
+
|
|
26
|
+
HOSTS = {
|
|
27
|
+
"youtube.com",
|
|
28
|
+
"www.youtube.com",
|
|
29
|
+
"m.youtube.com",
|
|
30
|
+
"music.youtube.com",
|
|
31
|
+
"youtu.be",
|
|
32
|
+
"www.youtu.be",
|
|
33
|
+
"youtube-nocookie.com",
|
|
34
|
+
"www.youtube-nocookie.com",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
struct PlayerClient
|
|
38
|
+
getter name : String
|
|
39
|
+
getter identifier : String
|
|
40
|
+
getter version : String
|
|
41
|
+
getter user_agent : String
|
|
42
|
+
getter context : Hash(String, String)
|
|
43
|
+
|
|
44
|
+
def initialize(
|
|
45
|
+
@name : String,
|
|
46
|
+
@identifier : String,
|
|
47
|
+
@version : String,
|
|
48
|
+
@user_agent : String,
|
|
49
|
+
@context : Hash(String, String),
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
struct Bootstrap
|
|
55
|
+
getter visitor_data : String?
|
|
56
|
+
getter api_key : String?
|
|
57
|
+
|
|
58
|
+
def initialize(@visitor_data : String? = nil, @api_key : String? = nil)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
VISIONOS = PlayerClient.new(
|
|
63
|
+
"visionos",
|
|
64
|
+
"101",
|
|
65
|
+
"1.02",
|
|
66
|
+
VISIONOS_USER_AGENT,
|
|
67
|
+
{
|
|
68
|
+
"clientName" => "VISIONOS",
|
|
69
|
+
"deviceMake" => "Apple",
|
|
70
|
+
"deviceModel" => "RealityDevice17,1",
|
|
71
|
+
"osName" => "visionOS",
|
|
72
|
+
"osVersion" => "26.5.23O471",
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
TV_SAMSUNG = PlayerClient.new(
|
|
77
|
+
"tv_samsung",
|
|
78
|
+
"7",
|
|
79
|
+
"5.20260707",
|
|
80
|
+
"Mozilla/5.0 (SMART-TV; Linux; Tizen 2.4.0) AppleWebKit/538.1 " \
|
|
81
|
+
"(KHTML, like Gecko) Version/2.4.0 TV Safari/538.1",
|
|
82
|
+
{
|
|
83
|
+
"clientName" => "TVHTML5",
|
|
84
|
+
"deviceMake" => "Samsung",
|
|
85
|
+
"deviceModel" => "UKS9800",
|
|
86
|
+
"osName" => "Tizen",
|
|
87
|
+
"osVersion" => "2.4.0",
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
CLIENTS = {VISIONOS, TV_SAMSUNG}
|
|
92
|
+
|
|
93
|
+
SEARCH_CLIENT = PlayerClient.new(
|
|
94
|
+
"web",
|
|
95
|
+
"1",
|
|
96
|
+
"2.20260707.01.00",
|
|
97
|
+
"Mozilla/5.0",
|
|
98
|
+
{"clientName" => "WEB"},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
struct SearchResult
|
|
102
|
+
getter video_id : String
|
|
103
|
+
getter title : String
|
|
104
|
+
getter duration_seconds : Float64?
|
|
105
|
+
|
|
106
|
+
def initialize(
|
|
107
|
+
@video_id : String,
|
|
108
|
+
@title : String,
|
|
109
|
+
@duration_seconds : Float64? = nil,
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def initialize(@api_base : String = DEFAULT_API_BASE)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def name : String
|
|
118
|
+
"youtube"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def matches?(url : URI) : Bool
|
|
122
|
+
return false unless url.scheme == "http" || url.scheme == "https"
|
|
123
|
+
|
|
124
|
+
!video_id_for(url).nil?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def extract(url : URI, client : HTTPClient) : Media
|
|
128
|
+
video_id = video_id_for(url)
|
|
129
|
+
unless video_id
|
|
130
|
+
raise UnsupportedUrlError.new(url.to_s)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
bootstrap = watch_page(client, video_id)
|
|
134
|
+
errors = [] of String
|
|
135
|
+
|
|
136
|
+
CLIENTS.each do |player_client|
|
|
137
|
+
begin
|
|
138
|
+
payload = player_payload(client, player_client, video_id, bootstrap, url)
|
|
139
|
+
|
|
140
|
+
if reason = playability_error(payload)
|
|
141
|
+
errors << "#{player_client.name}: #{reason}"
|
|
142
|
+
next
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
details = payload["videoDetails"]?.try(&.as_h?)
|
|
146
|
+
unless details
|
|
147
|
+
errors << "#{player_client.name}: video details are missing"
|
|
148
|
+
next
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
if details["isLiveContent"]?.try(&.as_bool?)
|
|
152
|
+
raise MediaUnavailableError.new(url.to_s, "live videos are not supported")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
formats = formats_from(payload["streamingData"]?)
|
|
156
|
+
if formats.empty?
|
|
157
|
+
errors << "#{player_client.name}: no direct formats; JavaScript challenge support is required"
|
|
158
|
+
next
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
return build_media(details, payload, formats, video_id, url)
|
|
162
|
+
rescue ex : MediaUnavailableError
|
|
163
|
+
errors << "#{player_client.name}: #{ex.message}"
|
|
164
|
+
rescue ex : HTTPError
|
|
165
|
+
errors << "#{player_client.name}: HTTP #{ex.status.code}"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
raise MediaUnavailableError.new(url.to_s, errors.uniq.join("; "))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def search(query : String, client : HTTPClient, limit : Int32 = 5) : Array(SearchResult)
|
|
173
|
+
body = JSON.build do |json|
|
|
174
|
+
json.object do
|
|
175
|
+
json.field "context" do
|
|
176
|
+
json.object do
|
|
177
|
+
json.field "client" do
|
|
178
|
+
json.object do
|
|
179
|
+
json.field "clientName", SEARCH_CLIENT.context["clientName"]
|
|
180
|
+
json.field "clientVersion", SEARCH_CLIENT.version
|
|
181
|
+
json.field "hl", "en"
|
|
182
|
+
json.field "gl", "US"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
json.field "query", query
|
|
188
|
+
json.field "params", "EgIQAQ%3D%3D"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
headers = HTTP::Headers.new
|
|
193
|
+
headers["User-Agent"] = SEARCH_CLIENT.user_agent
|
|
194
|
+
headers["X-YouTube-Client-Name"] = SEARCH_CLIENT.identifier
|
|
195
|
+
headers["X-YouTube-Client-Version"] = SEARCH_CLIENT.version
|
|
196
|
+
headers["Origin"] = @api_base
|
|
197
|
+
headers["Referer"] = "#{@api_base}/"
|
|
198
|
+
|
|
199
|
+
endpoint = "#{@api_base}/youtubei/v1/search?key=#{API_KEY}&prettyPrint=false"
|
|
200
|
+
response = client.post(endpoint, body, headers)
|
|
201
|
+
payload = JSON.parse(response.body)
|
|
202
|
+
|
|
203
|
+
results = [] of SearchResult
|
|
204
|
+
collect_search_results(payload, results, limit)
|
|
205
|
+
results
|
|
206
|
+
rescue JSON::ParseException
|
|
207
|
+
raise MediaUnavailableError.new("#{@api_base}/", "search response is not valid JSON")
|
|
208
|
+
rescue ex : HTTPError
|
|
209
|
+
raise MediaUnavailableError.new("#{@api_base}/", "search request failed (#{ex.status.code})")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private def collect_search_results(
|
|
213
|
+
value : JSON::Any,
|
|
214
|
+
results : Array(SearchResult),
|
|
215
|
+
limit : Int32,
|
|
216
|
+
) : Nil
|
|
217
|
+
return if results.size >= limit
|
|
218
|
+
|
|
219
|
+
if hash = value.as_h?
|
|
220
|
+
if renderer = hash["videoRenderer"]?.try(&.as_h?)
|
|
221
|
+
result = search_result(renderer)
|
|
222
|
+
results << result if result
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
hash.each_value do |child|
|
|
226
|
+
collect_search_results(child, results, limit)
|
|
227
|
+
return if results.size >= limit
|
|
228
|
+
end
|
|
229
|
+
elsif array = value.as_a?
|
|
230
|
+
array.each do |child|
|
|
231
|
+
collect_search_results(child, results, limit)
|
|
232
|
+
return if results.size >= limit
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
private def search_result(renderer : Hash(String, JSON::Any)) : SearchResult?
|
|
238
|
+
video_id = JsonValue.string(renderer["videoId"]?)
|
|
239
|
+
return nil unless video_id
|
|
240
|
+
|
|
241
|
+
title = text_runs(renderer["title"]?) || video_id
|
|
242
|
+
duration = Timestamp.clock_seconds(text_runs(renderer["lengthText"]?))
|
|
243
|
+
|
|
244
|
+
SearchResult.new(video_id, title, duration)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private def text_runs(value : JSON::Any?) : String?
|
|
248
|
+
hash = value.try(&.as_h?)
|
|
249
|
+
return nil unless hash
|
|
250
|
+
|
|
251
|
+
if runs = hash["runs"]?.try(&.as_a?)
|
|
252
|
+
text = runs.compact_map do |run|
|
|
253
|
+
run_hash = run.as_h?
|
|
254
|
+
JsonValue.string(run_hash.try(&.["text"]?))
|
|
255
|
+
end.join
|
|
256
|
+
|
|
257
|
+
return text unless text.empty?
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
JsonValue.string(hash["simpleText"]?)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
private def build_media(
|
|
264
|
+
details : Hash(String, JSON::Any),
|
|
265
|
+
payload : Hash(String, JSON::Any),
|
|
266
|
+
formats : Array(Format),
|
|
267
|
+
video_id : String,
|
|
268
|
+
url : URI,
|
|
269
|
+
) : Media
|
|
270
|
+
Media.new(
|
|
271
|
+
id: JsonValue.string(details["videoId"]?) || video_id,
|
|
272
|
+
title: JsonValue.string(details["title"]?) || video_id,
|
|
273
|
+
description: JsonValue.string(details["shortDescription"]?),
|
|
274
|
+
webpage_url: url,
|
|
275
|
+
uploader: JsonValue.string(details["author"]?),
|
|
276
|
+
duration_seconds: JsonValue.seconds(details["lengthSeconds"]?),
|
|
277
|
+
thumbnail_url: thumbnail_url(details),
|
|
278
|
+
upload_date: upload_date(payload),
|
|
279
|
+
extractor: name,
|
|
280
|
+
formats: formats,
|
|
281
|
+
)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
private def watch_page(client : HTTPClient, video_id : String) : Bootstrap
|
|
285
|
+
headers = HTTP::Headers.new
|
|
286
|
+
headers["User-Agent"] = WATCH_PAGE_USER_AGENT
|
|
287
|
+
headers["Accept-Language"] = "en-US,en;q=0.9"
|
|
288
|
+
|
|
289
|
+
endpoint = "#{@api_base}/watch?v=#{video_id}&bpctr=9999999999&has_verified=1"
|
|
290
|
+
body = client.get(endpoint, headers).body
|
|
291
|
+
|
|
292
|
+
visitor = body.match(/"VISITOR_DATA":"([^"]+)"/) ||
|
|
293
|
+
body.match(/"visitorData":"([^"]+)"/)
|
|
294
|
+
api_key = body.match(/"INNERTUBE_API_KEY":"([^"]+)"/)
|
|
295
|
+
|
|
296
|
+
Bootstrap.new(visitor.try(&.[1]), api_key.try(&.[1]))
|
|
297
|
+
rescue Rill::Error
|
|
298
|
+
Bootstrap.new
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
private def player_payload(
|
|
302
|
+
client : HTTPClient,
|
|
303
|
+
player_client : PlayerClient,
|
|
304
|
+
video_id : String,
|
|
305
|
+
bootstrap : Bootstrap,
|
|
306
|
+
url : URI,
|
|
307
|
+
) : Hash(String, JSON::Any)
|
|
308
|
+
body = JSON.build do |json|
|
|
309
|
+
json.object do
|
|
310
|
+
json.field "context" do
|
|
311
|
+
json.object do
|
|
312
|
+
json.field "client" do
|
|
313
|
+
json.object do
|
|
314
|
+
player_client.context.each do |key, value|
|
|
315
|
+
json.field key, value
|
|
316
|
+
end
|
|
317
|
+
json.field "clientVersion", player_client.version
|
|
318
|
+
json.field "hl", "en"
|
|
319
|
+
json.field "timeZone", "UTC"
|
|
320
|
+
json.field "utcOffsetMinutes", 0
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
json.field "videoId", video_id
|
|
326
|
+
json.field "contentCheckOk", true
|
|
327
|
+
json.field "racyCheckOk", true
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
headers = HTTP::Headers.new
|
|
332
|
+
headers["User-Agent"] = player_client.user_agent
|
|
333
|
+
headers["X-YouTube-Client-Name"] = player_client.identifier
|
|
334
|
+
headers["X-YouTube-Client-Version"] = player_client.version
|
|
335
|
+
headers["Origin"] = @api_base
|
|
336
|
+
headers["Referer"] = "#{@api_base}/"
|
|
337
|
+
if visitor = bootstrap.visitor_data
|
|
338
|
+
headers["X-Goog-Visitor-Id"] = visitor
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
api_key = bootstrap.api_key || API_KEY
|
|
342
|
+
endpoint = "#{@api_base}/youtubei/v1/player?key=#{api_key}&prettyPrint=false"
|
|
343
|
+
response = client.post(endpoint, body, headers)
|
|
344
|
+
|
|
345
|
+
payload = JSON.parse(response.body).as_h?
|
|
346
|
+
unless payload
|
|
347
|
+
raise MediaUnavailableError.new(url.to_s, "player response is not a JSON object")
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
payload
|
|
351
|
+
rescue JSON::ParseException
|
|
352
|
+
raise MediaUnavailableError.new(url.to_s, "player response is not valid JSON")
|
|
353
|
+
rescue ex : HTTPError
|
|
354
|
+
case ex.status.code
|
|
355
|
+
when 400, 403, 429
|
|
356
|
+
raise MediaUnavailableError.new(url.to_s, "player API rejected the request (#{ex.status.code})")
|
|
357
|
+
else
|
|
358
|
+
raise ex
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
private def playability_error(payload : Hash(String, JSON::Any)) : String?
|
|
363
|
+
status = payload["playabilityStatus"]?.try(&.as_h?)
|
|
364
|
+
state = status.try(&.["status"]?).try(&.as_s?)
|
|
365
|
+
return nil if state == "OK"
|
|
366
|
+
|
|
367
|
+
reason = status.try(&.["reason"]?).try(&.as_s?)
|
|
368
|
+
reason || "video is not playable (#{state || "unknown"})"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
private def formats_from(streaming_value : JSON::Any?) : Array(Format)
|
|
372
|
+
streaming = streaming_value.try(&.as_h?)
|
|
373
|
+
return [] of Format unless streaming
|
|
374
|
+
|
|
375
|
+
formats = [] of Format
|
|
376
|
+
seen = Set(String).new
|
|
377
|
+
|
|
378
|
+
{"formats", "adaptiveFormats"}.each do |key|
|
|
379
|
+
items = streaming[key]?.try(&.as_a?) || [] of JSON::Any
|
|
380
|
+
|
|
381
|
+
items.each do |item|
|
|
382
|
+
format = format_from(item)
|
|
383
|
+
next unless format
|
|
384
|
+
next if seen.includes?(format.format_id)
|
|
385
|
+
|
|
386
|
+
seen << format.format_id
|
|
387
|
+
formats << format
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
formats
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
private def format_from(item : JSON::Any) : Format?
|
|
395
|
+
value = item.as_h?
|
|
396
|
+
return nil unless value
|
|
397
|
+
|
|
398
|
+
url = HttpUrl.absolute(JsonValue.string(value["url"]?))
|
|
399
|
+
return nil unless url
|
|
400
|
+
return nil if value["signatureCipher"]?
|
|
401
|
+
|
|
402
|
+
itag = JsonValue.integer(value["itag"]?)
|
|
403
|
+
return nil unless itag
|
|
404
|
+
|
|
405
|
+
mime_type = JsonValue.string(value["mimeType"]?) || ""
|
|
406
|
+
mime = MimeType.parse(mime_type)
|
|
407
|
+
return nil if mime.empty?
|
|
408
|
+
|
|
409
|
+
width = JsonValue.dimension(value["width"]?)
|
|
410
|
+
height = JsonValue.dimension(value["height"]?)
|
|
411
|
+
has_video = mime.video? && (!width.nil? || !height.nil?)
|
|
412
|
+
has_audio = mime.audio? ||
|
|
413
|
+
(JsonValue.integer(value["audioChannels"]?) || 0) > 0
|
|
414
|
+
return nil unless has_video || has_audio
|
|
415
|
+
|
|
416
|
+
codec_list = mime.codec_list
|
|
417
|
+
|
|
418
|
+
video_codec = if has_video
|
|
419
|
+
codec_list.first?
|
|
420
|
+
end
|
|
421
|
+
audio_codec = if has_audio
|
|
422
|
+
has_video ? codec_list[1]? : codec_list.first?
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
Format.new(
|
|
426
|
+
format_id: itag.to_s,
|
|
427
|
+
url: url,
|
|
428
|
+
kind: format_kind(has_video, has_audio),
|
|
429
|
+
container: mime.container,
|
|
430
|
+
bitrate: JsonValue.integer(value["bitrate"]?),
|
|
431
|
+
video_codec: video_codec,
|
|
432
|
+
audio_codec: audio_codec,
|
|
433
|
+
width: width,
|
|
434
|
+
height: height,
|
|
435
|
+
fps: JsonValue.positive_number(value["fps"]?),
|
|
436
|
+
filesize: JsonValue.integer(value["contentLength"]?),
|
|
437
|
+
range_chunk_size: 1024_i64 * 1024,
|
|
438
|
+
language: audio_language(value),
|
|
439
|
+
note: JsonValue.string(value["qualityLabel"]?) || JsonValue.string(value["audioQuality"]?),
|
|
440
|
+
)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
private def format_kind(has_video : Bool, has_audio : Bool) : FormatKind
|
|
444
|
+
if has_video && has_audio
|
|
445
|
+
FormatKind::Combined
|
|
446
|
+
elsif has_video
|
|
447
|
+
FormatKind::Video
|
|
448
|
+
else
|
|
449
|
+
FormatKind::Audio
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
private def audio_language(value : Hash(String, JSON::Any)) : String?
|
|
454
|
+
track = value["audioTrack"]?.try(&.as_h?)
|
|
455
|
+
return nil unless track
|
|
456
|
+
|
|
457
|
+
JsonValue.string(track["displayName"]?) || JsonValue.string(track["id"]?)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
private def thumbnail_url(details : Hash(String, JSON::Any)) : URI?
|
|
461
|
+
thumbnail = details["thumbnail"]?.try(&.as_h?)
|
|
462
|
+
return nil unless thumbnail
|
|
463
|
+
|
|
464
|
+
images = thumbnail["thumbnails"]?.try(&.as_a?)
|
|
465
|
+
return nil unless images
|
|
466
|
+
|
|
467
|
+
images.reverse_each do |image|
|
|
468
|
+
image_hash = image.as_h?
|
|
469
|
+
next unless image_hash
|
|
470
|
+
|
|
471
|
+
uri = HttpUrl.absolute(JsonValue.string(image_hash["url"]?))
|
|
472
|
+
return uri if uri
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
nil
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
private def upload_date(payload : Hash(String, JSON::Any)) : String?
|
|
479
|
+
microformat = payload["microformat"]?.try(&.as_h?)
|
|
480
|
+
return nil unless microformat
|
|
481
|
+
|
|
482
|
+
renderer = microformat["playerMicroformatRenderer"]?.try(&.as_h?)
|
|
483
|
+
return nil unless renderer
|
|
484
|
+
|
|
485
|
+
value = JsonValue.string(renderer["publishDate"]?) || JsonValue.string(renderer["uploadDate"]?)
|
|
486
|
+
return nil unless value
|
|
487
|
+
return nil if value.size < 10
|
|
488
|
+
|
|
489
|
+
value[0, 10]
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
private def video_id_for(url : URI) : String?
|
|
493
|
+
host = url.host.try(&.downcase)
|
|
494
|
+
return nil unless host && HOSTS.includes?(host)
|
|
495
|
+
|
|
496
|
+
path = url.path
|
|
497
|
+
|
|
498
|
+
if host.ends_with?("youtu.be")
|
|
499
|
+
return valid_id?(path.lchop('/').split('/').first?)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
if path == "/watch"
|
|
503
|
+
query = HTTP::Params.parse(url.query || "")
|
|
504
|
+
return valid_id?(query["v"]?)
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
match = %r{\A/(?:shorts|embed|live|v)/([^/]+)/?\z}.match(path)
|
|
508
|
+
return nil unless match
|
|
509
|
+
|
|
510
|
+
valid_id?(match[1])
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
private def valid_id?(value : String?) : String?
|
|
514
|
+
return nil unless value
|
|
515
|
+
return nil unless VIDEO_ID.matches?(value)
|
|
516
|
+
|
|
517
|
+
value
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
module Rill
|
|
5
|
+
enum FormatKind
|
|
6
|
+
Video
|
|
7
|
+
Audio
|
|
8
|
+
Combined
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
enum FormatProtocol
|
|
12
|
+
Http
|
|
13
|
+
Hls
|
|
14
|
+
Dash
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class Format
|
|
18
|
+
getter format_id : String
|
|
19
|
+
getter url : URI
|
|
20
|
+
getter kind : FormatKind
|
|
21
|
+
getter protocol : FormatProtocol
|
|
22
|
+
getter container : String?
|
|
23
|
+
getter bitrate : Int64?
|
|
24
|
+
getter video_codec : String?
|
|
25
|
+
getter audio_codec : String?
|
|
26
|
+
getter width : Int32?
|
|
27
|
+
getter height : Int32?
|
|
28
|
+
getter fps : Float64?
|
|
29
|
+
getter video_bitrate : Int32?
|
|
30
|
+
getter audio_bitrate : Int32?
|
|
31
|
+
getter filesize : Int64?
|
|
32
|
+
getter range_chunk_size : Int64?
|
|
33
|
+
getter duration_seconds : Float64?
|
|
34
|
+
getter language : String?
|
|
35
|
+
getter note : String?
|
|
36
|
+
|
|
37
|
+
def initialize(
|
|
38
|
+
@format_id : String,
|
|
39
|
+
@url : URI,
|
|
40
|
+
@kind : FormatKind = FormatKind::Combined,
|
|
41
|
+
@protocol : FormatProtocol = FormatProtocol::Http,
|
|
42
|
+
@container : String? = nil,
|
|
43
|
+
@bitrate : Int64? = nil,
|
|
44
|
+
@video_codec : String? = nil,
|
|
45
|
+
@audio_codec : String? = nil,
|
|
46
|
+
@width : Int32? = nil,
|
|
47
|
+
@height : Int32? = nil,
|
|
48
|
+
@fps : Float64? = nil,
|
|
49
|
+
@video_bitrate : Int32? = nil,
|
|
50
|
+
@audio_bitrate : Int32? = nil,
|
|
51
|
+
@filesize : Int64? = nil,
|
|
52
|
+
@range_chunk_size : Int64? = nil,
|
|
53
|
+
@duration_seconds : Float64? = nil,
|
|
54
|
+
@language : String? = nil,
|
|
55
|
+
@note : String? = nil,
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to_json(json : JSON::Builder) : Nil
|
|
60
|
+
json.object do
|
|
61
|
+
json.field "format_id", format_id
|
|
62
|
+
json.field "url", url.to_s
|
|
63
|
+
json.field "kind", kind.to_s.downcase
|
|
64
|
+
json.field "protocol", protocol.to_s.downcase
|
|
65
|
+
json.field "container", container
|
|
66
|
+
json.field "bitrate", bitrate
|
|
67
|
+
json.field "video_codec", video_codec
|
|
68
|
+
json.field "audio_codec", audio_codec
|
|
69
|
+
json.field "width", width
|
|
70
|
+
json.field "height", height
|
|
71
|
+
json.field "fps", fps
|
|
72
|
+
json.field "video_bitrate", video_bitrate
|
|
73
|
+
json.field "audio_bitrate", audio_bitrate
|
|
74
|
+
json.field "filesize", filesize
|
|
75
|
+
json.field "range_chunk_size", range_chunk_size
|
|
76
|
+
json.field "duration_seconds", duration_seconds
|
|
77
|
+
json.field "language", language
|
|
78
|
+
json.field "note", note
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|