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,495 @@
1
+ require "base64"
2
+ require "json"
3
+ require "uri"
4
+
5
+ require "../errors"
6
+ require "../extractor"
7
+ require "../format"
8
+ require "../http_url"
9
+ require "../json_value"
10
+ require "../media"
11
+ require "../playlist"
12
+
13
+ module Rill::Extractors
14
+ class Mixcloud < Extractor
15
+ DEFAULT_API_BASE = "https://app.mixcloud.com"
16
+ DEFAULT_LIST = "uploads"
17
+ ISO_DATE = /\A\d{4}-\d{2}-\d{2}/
18
+ DECRYPTION_KEY = "IFYOUWANTTHEARTISTSTOGETPAIDDONOTDOWNLOADFROMMIXCLOUD"
19
+ FIELDS = "name description audioLength publishDate restrictedReason " \
20
+ "picture(width: 1024, height: 1024) { url } " \
21
+ "owner { displayName username url } " \
22
+ "streamInfo { url hlsUrl dashUrl }"
23
+
24
+ RESERVED_SLUGS = %w(stream uploads favorites listens playlists)
25
+
26
+ CLOUDCAST_PATH = %r{\A/([^/]+)/([^/]+)/?\z}
27
+ USER_LIST_PATH = %r{\A/([^/]+)/(uploads|favorites|listens|stream)/?\z}
28
+ PLAYLIST_PATH = %r{\A/([^/]+)/playlists/([^/]+)/?\z}
29
+ USER_PATH = %r{\A/([^/]+)/?\z}
30
+
31
+ struct CloudcastRoute
32
+ getter username : String
33
+ getter slug : String
34
+
35
+ def initialize(@username : String, @slug : String)
36
+ end
37
+ end
38
+
39
+ struct UserRoute
40
+ getter username : String
41
+ getter list : String
42
+
43
+ def initialize(@username : String, @list : String)
44
+ end
45
+ end
46
+
47
+ struct PlaylistRoute
48
+ getter username : String
49
+ getter slug : String
50
+
51
+ def initialize(@username : String, @slug : String)
52
+ end
53
+ end
54
+
55
+ alias Route = CloudcastRoute | UserRoute | PlaylistRoute
56
+
57
+ def initialize(@api_base : String = DEFAULT_API_BASE)
58
+ @api_base = @api_base.rstrip('/')
59
+ end
60
+
61
+ def name : String
62
+ "mixcloud"
63
+ end
64
+
65
+ def matches?(url : URI) : Bool
66
+ return false unless url.scheme == "http" || url.scheme == "https"
67
+
68
+ case url.host.try(&.downcase)
69
+ when "mixcloud.com", "www.mixcloud.com", "beta.mixcloud.com", "m.mixcloud.com"
70
+ !lookup(url.path).nil?
71
+ else
72
+ false
73
+ end
74
+ end
75
+
76
+ def extract(url : URI, client : HTTPClient) : Media | Playlist
77
+ route = lookup(url.path)
78
+ unless route
79
+ raise UnsupportedUrlError.new(url.to_s)
80
+ end
81
+
82
+ case route
83
+ when CloudcastRoute
84
+ extract_cloudcast(route, url, client)
85
+ when UserRoute
86
+ extract_user(route, url, client)
87
+ when PlaylistRoute
88
+ extract_playlist(route, url, client)
89
+ else
90
+ raise UnsupportedUrlError.new(url.to_s)
91
+ end
92
+ end
93
+
94
+ private def lookup(path : String) : Route?
95
+ if match = PLAYLIST_PATH.match(path)
96
+ return PlaylistRoute.new(match[1], match[2])
97
+ end
98
+
99
+ if match = USER_LIST_PATH.match(path)
100
+ return UserRoute.new(match[1], match[2])
101
+ end
102
+
103
+ if match = CLOUDCAST_PATH.match(path)
104
+ username = match[1]
105
+ slug = match[2]
106
+ return CloudcastRoute.new(username, slug) unless RESERVED_SLUGS.includes?(slug.downcase)
107
+ end
108
+
109
+ match = USER_PATH.match(path)
110
+ return nil unless match
111
+
112
+ UserRoute.new(match[1], DEFAULT_LIST)
113
+ end
114
+
115
+ private def extract_cloudcast(
116
+ route : CloudcastRoute,
117
+ url : URI,
118
+ client : HTTPClient,
119
+ ) : Media
120
+ cloudcast = fetch_cloudcast(client, route.username, route.slug, url)
121
+ check_restrictions(cloudcast, url)
122
+ title = title(cloudcast, url)
123
+ duration = JsonValue.seconds(cloudcast["audioLength"]?)
124
+ format = audio_format(cloudcast, url, duration)
125
+
126
+ Media.new(
127
+ id: "#{route.username}_#{route.slug}",
128
+ title: title,
129
+ description: JsonValue.string(cloudcast["description"]?),
130
+ webpage_url: url,
131
+ uploader: uploader(cloudcast),
132
+ duration_seconds: duration,
133
+ thumbnail_url: thumbnail_url(cloudcast),
134
+ upload_date: upload_date(cloudcast),
135
+ extractor: name,
136
+ formats: [format],
137
+ )
138
+ end
139
+
140
+ private def extract_user(
141
+ route : UserRoute,
142
+ url : URI,
143
+ client : HTTPClient,
144
+ ) : Playlist
145
+ entries = [] of PlaylistEntry
146
+ display_name = nil
147
+ biography = nil
148
+ cursor = nil
149
+
150
+ loop do
151
+ query = user_query(route.username, route.list, cursor)
152
+ user = graphql_root(client, query, "userLookup", url, "user")
153
+ display_name ||= JsonValue.string(user["displayName"]?)
154
+ biography ||= JsonValue.string(user["biog"]?)
155
+ connection = user[route.list]?.try(&.as_h?)
156
+ break unless connection
157
+
158
+ append_cloudcast_entries(connection, entries)
159
+ cursor = next_cursor(connection)
160
+ break unless cursor
161
+ end
162
+
163
+ if entries.empty?
164
+ raise MediaUnavailableError.new(url.to_s, "user has no #{route.list} entries")
165
+ end
166
+
167
+ title = display_name || route.username
168
+
169
+ Playlist.new(
170
+ id: "#{route.username}_#{route.list}",
171
+ title: "#{title} (#{route.list})",
172
+ description: biography,
173
+ webpage_url: url,
174
+ extractor: name,
175
+ entries: entries,
176
+ )
177
+ end
178
+
179
+ private def extract_playlist(
180
+ route : PlaylistRoute,
181
+ url : URI,
182
+ client : HTTPClient,
183
+ ) : Playlist
184
+ entries = [] of PlaylistEntry
185
+ playlist_name = nil
186
+ description = nil
187
+ cursor = nil
188
+
189
+ loop do
190
+ query = playlist_query(route.username, route.slug, cursor)
191
+ playlist = graphql_root(client, query, "playlistLookup", url, "playlist")
192
+ playlist_name ||= JsonValue.string(playlist["name"]?)
193
+ description ||= JsonValue.string(playlist["description"]?)
194
+ connection = playlist["items"]?.try(&.as_h?)
195
+ break unless connection
196
+
197
+ append_playlist_entries(connection, entries)
198
+ cursor = next_cursor(connection)
199
+ break unless cursor
200
+ end
201
+
202
+ if entries.empty?
203
+ raise MediaUnavailableError.new(url.to_s, "playlist has no entries")
204
+ end
205
+
206
+ title = playlist_name
207
+ unless title
208
+ raise MediaUnavailableError.new(url.to_s, "playlist name is missing")
209
+ end
210
+
211
+ Playlist.new(
212
+ id: "#{route.username}_#{route.slug}",
213
+ title: title,
214
+ description: description,
215
+ webpage_url: url,
216
+ extractor: name,
217
+ entries: entries,
218
+ )
219
+ end
220
+
221
+ private def fetch_cloudcast(
222
+ client : HTTPClient,
223
+ username : String,
224
+ slug : String,
225
+ url : URI,
226
+ ) : Hash(String, JSON::Any)
227
+ query = cloudcast_query(username, slug)
228
+ graphql_root(client, query, "cloudcastLookup", url, "cloudcast")
229
+ end
230
+
231
+ private def graphql_root(
232
+ client : HTTPClient,
233
+ query : String,
234
+ lookup : String,
235
+ url : URI,
236
+ label : String,
237
+ ) : Hash(String, JSON::Any)
238
+ endpoint = URI.parse("#{@api_base}/graphql")
239
+ endpoint.query = URI::Params.encode({"query" => query})
240
+ response = client.get(endpoint)
241
+ payload = JSON.parse(response.body)
242
+ data = payload["data"]?.try(&.as_h?)
243
+ root = data.try { |hash| hash[lookup]?.try(&.as_h?) }
244
+ unless root
245
+ raise MediaUnavailableError.new(url.to_s, "#{label} was not found")
246
+ end
247
+
248
+ root
249
+ rescue JSON::ParseException
250
+ raise MediaUnavailableError.new(url.to_s, "GraphQL response is not valid JSON")
251
+ rescue ex : HTTPError
252
+ if ex.status.code == 404
253
+ raise MediaUnavailableError.new(url.to_s, "#{label} was not found")
254
+ end
255
+
256
+ raise ex
257
+ end
258
+
259
+ private def cloudcast_query(username : String, slug : String) : String
260
+ <<-GRAPHQL
261
+ {
262
+ cloudcastLookup(lookup: {username: #{username.to_json}, slug: #{slug.to_json}}) {
263
+ #{FIELDS}
264
+ }
265
+ }
266
+ GRAPHQL
267
+ end
268
+
269
+ private def user_query(username : String, list : String, cursor : String?) : String
270
+ after = cursor_clause(cursor)
271
+ <<-GRAPHQL
272
+ {
273
+ userLookup(lookup: {username: #{username.to_json}}) {
274
+ displayName
275
+ biog
276
+ #{list}(first: 100#{after}) {
277
+ edges {
278
+ node {
279
+ name
280
+ slug
281
+ url
282
+ owner { username }
283
+ }
284
+ }
285
+ pageInfo {
286
+ endCursor
287
+ hasNextPage
288
+ }
289
+ }
290
+ }
291
+ }
292
+ GRAPHQL
293
+ end
294
+
295
+ private def playlist_query(username : String, slug : String, cursor : String?) : String
296
+ after = cursor_clause(cursor)
297
+ <<-GRAPHQL
298
+ {
299
+ playlistLookup(lookup: {username: #{username.to_json}, slug: #{slug.to_json}}) {
300
+ name
301
+ description
302
+ items(first: 100#{after}) {
303
+ edges {
304
+ node {
305
+ cloudcast {
306
+ name
307
+ slug
308
+ url
309
+ owner { username }
310
+ }
311
+ }
312
+ }
313
+ pageInfo {
314
+ endCursor
315
+ hasNextPage
316
+ }
317
+ }
318
+ }
319
+ }
320
+ GRAPHQL
321
+ end
322
+
323
+ private def cursor_clause(cursor : String?) : String
324
+ return "" unless cursor
325
+
326
+ ", after: #{cursor.to_json}"
327
+ end
328
+
329
+ private def append_cloudcast_entries(
330
+ connection : Hash(String, JSON::Any),
331
+ entries : Array(PlaylistEntry),
332
+ ) : Nil
333
+ edges = connection["edges"]?.try(&.as_a?)
334
+ return unless edges
335
+
336
+ edges.each do |edge|
337
+ node = edge.as_h?
338
+ next unless node
339
+
340
+ cloudcast = node["node"]?.try(&.as_h?)
341
+ next unless cloudcast
342
+
343
+ entry = cloudcast_entry(cloudcast)
344
+ entries << entry if entry
345
+ end
346
+ end
347
+
348
+ private def append_playlist_entries(
349
+ connection : Hash(String, JSON::Any),
350
+ entries : Array(PlaylistEntry),
351
+ ) : Nil
352
+ edges = connection["edges"]?.try(&.as_a?)
353
+ return unless edges
354
+
355
+ edges.each do |edge|
356
+ node = edge.as_h?
357
+ next unless node
358
+
359
+ item = node["node"]?.try(&.as_h?)
360
+ next unless item
361
+
362
+ cloudcast = item["cloudcast"]?.try(&.as_h?)
363
+ next unless cloudcast
364
+
365
+ entry = cloudcast_entry(cloudcast)
366
+ entries << entry if entry
367
+ end
368
+ end
369
+
370
+ private def cloudcast_entry(cloudcast : Hash(String, JSON::Any)) : PlaylistEntry?
371
+ cloudcast_url = HttpUrl.absolute(JsonValue.string(cloudcast["url"]?))
372
+ return nil unless cloudcast_url
373
+
374
+ slug = JsonValue.string(cloudcast["slug"]?)
375
+ owner = cloudcast["owner"]?.try(&.as_h?)
376
+ owner_username = owner.try { |hash| JsonValue.string(hash["username"]?) }
377
+ id = if slug && owner_username
378
+ "#{owner_username}_#{slug}"
379
+ end
380
+
381
+ PlaylistEntry.new(
382
+ url: cloudcast_url,
383
+ title: JsonValue.string(cloudcast["name"]?),
384
+ id: id,
385
+ )
386
+ end
387
+
388
+ private def next_cursor(connection : Hash(String, JSON::Any)) : String?
389
+ page_info = connection["pageInfo"]?.try(&.as_h?)
390
+ return nil unless page_info
391
+ return nil unless page_info["hasNextPage"]?.try(&.as_bool?)
392
+
393
+ JsonValue.string(page_info["endCursor"]?)
394
+ end
395
+
396
+ private def check_restrictions(cloudcast : Hash(String, JSON::Any), url : URI) : Nil
397
+ reason = JsonValue.string(cloudcast["restrictedReason"]?)
398
+ return if reason.nil? || reason.empty?
399
+
400
+ case reason
401
+ when "tracklist"
402
+ raise MediaUnavailableError.new(url.to_s, "track is unavailable due to licensing restrictions")
403
+ when "repeat_play"
404
+ raise MediaUnavailableError.new(url.to_s, "play limit was reached")
405
+ else
406
+ raise MediaUnavailableError.new(url.to_s, "track is restricted")
407
+ end
408
+ end
409
+
410
+ private def title(cloudcast : Hash(String, JSON::Any), url : URI) : String
411
+ value = JsonValue.string(cloudcast["name"]?)
412
+ unless value
413
+ raise MediaUnavailableError.new(url.to_s, "cloudcast name is missing")
414
+ end
415
+
416
+ value
417
+ end
418
+
419
+ private def uploader(cloudcast : Hash(String, JSON::Any)) : String?
420
+ owner = cloudcast["owner"]?.try(&.as_h?)
421
+ return nil unless owner
422
+
423
+ JsonValue.string(owner["displayName"]?) || JsonValue.string(owner["username"]?)
424
+ end
425
+
426
+ private def thumbnail_url(cloudcast : Hash(String, JSON::Any)) : URI?
427
+ picture = cloudcast["picture"]?.try(&.as_h?)
428
+ value = picture.try { |hash| JsonValue.string(hash["url"]?) }
429
+ HttpUrl.absolute(value)
430
+ end
431
+
432
+ private def upload_date(cloudcast : Hash(String, JSON::Any)) : String?
433
+ value = JsonValue.string(cloudcast["publishDate"]?)
434
+ return nil unless value
435
+ return value[0, 10] if ISO_DATE.match(value)
436
+
437
+ nil
438
+ end
439
+
440
+ private def audio_format(
441
+ cloudcast : Hash(String, JSON::Any),
442
+ url : URI,
443
+ duration : Float64?,
444
+ ) : Format
445
+ info = cloudcast["streamInfo"]?.try(&.as_h?)
446
+ if info
447
+ if stream_url = decrypt_stream_url(info["url"]?)
448
+ return stream_format("progressive", FormatProtocol::Http, stream_url, duration)
449
+ end
450
+
451
+ if stream_url = decrypt_stream_url(info["hlsUrl"]?)
452
+ return stream_format("hls", FormatProtocol::Hls, stream_url, duration)
453
+ end
454
+
455
+ if stream_url = decrypt_stream_url(info["dashUrl"]?)
456
+ return stream_format("dash", FormatProtocol::Dash, stream_url, duration)
457
+ end
458
+ end
459
+
460
+ raise MediaUnavailableError.new(url.to_s, "cloudcast has no playable stream")
461
+ end
462
+
463
+ private def decrypt_stream_url(value : JSON::Any?) : URI?
464
+ encoded = JsonValue.string(value)
465
+ return nil unless encoded
466
+
467
+ cipher = Base64.decode(encoded)
468
+ plain = String.build(cipher.size) do |io|
469
+ cipher.each_with_index do |byte, index|
470
+ io.write_byte(byte ^ DECRYPTION_KEY.to_slice[index % DECRYPTION_KEY.bytesize])
471
+ end
472
+ end
473
+
474
+ HttpUrl.absolute(plain)
475
+ rescue Base64::Error
476
+ nil
477
+ end
478
+
479
+ private def stream_format(
480
+ format_id : String,
481
+ protocol : FormatProtocol,
482
+ stream_url : URI,
483
+ duration : Float64?,
484
+ ) : Format
485
+ Format.new(
486
+ format_id: format_id,
487
+ url: stream_url,
488
+ kind: FormatKind::Audio,
489
+ protocol: protocol,
490
+ container: "m4a",
491
+ duration_seconds: duration,
492
+ )
493
+ end
494
+ end
495
+ end
@@ -0,0 +1,226 @@
1
+ require "json"
2
+ require "set"
3
+ require "uri"
4
+
5
+ require "../errors"
6
+ require "../extractor"
7
+ require "../format"
8
+ require "../http_url"
9
+ require "../json_value"
10
+ require "../media"
11
+
12
+ module Rill::Extractors
13
+ class PeerTube < Extractor
14
+ VIDEO_PATH = %r{\A/(?:w|videos/(?:watch|embed))/([A-Za-z0-9-]+)/?\z}
15
+
16
+ def initialize(@api_base : String? = nil)
17
+ end
18
+
19
+ def name : String
20
+ "peertube"
21
+ end
22
+
23
+ def matches?(url : URI) : Bool
24
+ return false unless url.scheme == "http" || url.scheme == "https"
25
+
26
+ !VIDEO_PATH.match(url.path).nil?
27
+ end
28
+
29
+ def extract(url : URI, client : HTTPClient) : Media
30
+ identifier = identifier_for(url)
31
+ unless identifier
32
+ raise UnsupportedUrlError.new(url.to_s)
33
+ end
34
+
35
+ api_url = URI.parse("#{api_base(url)}/api/v1/videos/#{URI.encode_path_segment(identifier)}")
36
+ payload = api_payload(client, api_url, url)
37
+
38
+ if payload["isLive"]?.try(&.as_bool?)
39
+ raise MediaUnavailableError.new(url.to_s, "live videos are not supported")
40
+ end
41
+
42
+ formats = formats_from(payload)
43
+ if formats.empty?
44
+ raise MediaUnavailableError.new(url.to_s, "video has no downloadable files")
45
+ end
46
+
47
+ Media.new(
48
+ id: JsonValue.string(payload["uuid"]?) || identifier,
49
+ title: JsonValue.string(payload["name"]?) || identifier,
50
+ description: JsonValue.string(payload["description"]?),
51
+ webpage_url: url,
52
+ uploader: uploader(payload),
53
+ duration_seconds: JsonValue.number(payload["duration"]?),
54
+ thumbnail_url: thumbnail_url(url, payload),
55
+ upload_date: date_value(payload),
56
+ extractor: name,
57
+ formats: formats,
58
+ )
59
+ end
60
+
61
+ private def identifier_for(url : URI) : String?
62
+ match = VIDEO_PATH.match(url.path)
63
+ return nil unless match
64
+
65
+ identifier = URI.decode(match[1])
66
+ identifier.empty? ? nil : identifier
67
+ end
68
+
69
+ private def api_base(url : URI) : String
70
+ @api_base || begin
71
+ port = url.port
72
+ if port
73
+ "#{url.scheme}://#{url.host}:#{port}"
74
+ else
75
+ "#{url.scheme}://#{url.host}"
76
+ end
77
+ end
78
+ end
79
+
80
+ private def api_payload(client : HTTPClient, api_url : URI, url : URI) : Hash(String, JSON::Any)
81
+ response = client.get(api_url)
82
+ payload = JSON.parse(response.body).as_h?
83
+ unless payload
84
+ raise MediaUnavailableError.new(url.to_s, "video metadata is not a JSON object")
85
+ end
86
+
87
+ payload
88
+ rescue ex : HTTPError
89
+ case ex.status.code
90
+ when 401, 403, 404
91
+ raise MediaUnavailableError.new(url.to_s, "video is unavailable, private, or protected")
92
+ else
93
+ raise ex
94
+ end
95
+ rescue JSON::ParseException
96
+ raise MediaUnavailableError.new(url.to_s, "video metadata is not valid JSON")
97
+ end
98
+
99
+ private def formats_from(payload : Hash(String, JSON::Any)) : Array(Format)
100
+ duration = JsonValue.number(payload["duration"]?)
101
+ language = language_for(payload)
102
+ formats = [] of Format
103
+ seen = Set(String).new
104
+
105
+ files = payload["files"]?.try(&.as_a?) || [] of JSON::Any
106
+ files.each_with_index do |file, index|
107
+ file_hash = file.as_h?
108
+ next unless file_hash
109
+
110
+ file_url = HttpUrl.absolute(JsonValue.string(file_hash["fileUrl"]?))
111
+ next unless file_url
112
+
113
+ resolution = file_hash["resolution"]?.try(&.as_h?)
114
+ height = resolution.try(&.["id"]?).try(&.as_i64?)
115
+ height = nil if height && (height <= 0 || height > 100_000)
116
+ label = resolution.try(&.["label"]?).try(&.as_s?)
117
+ audio_only = height.nil?
118
+
119
+ format_id = audio_only ? "audio" : "#{height}p"
120
+ if seen.includes?(format_id)
121
+ format_id = "#{format_id}-#{index + 1}"
122
+ end
123
+ seen << format_id
124
+
125
+ size = integer(file_hash["size"]?)
126
+ formats << Format.new(
127
+ format_id: format_id,
128
+ url: file_url,
129
+ kind: audio_only ? FormatKind::Audio : FormatKind::Combined,
130
+ container: container_for(file_url),
131
+ bitrate: bitrate(size, duration),
132
+ height: audio_only ? nil : height.try(&.to_i32),
133
+ fps: JsonValue.positive_number(file_hash["fps"]?),
134
+ filesize: size,
135
+ language: language,
136
+ note: label,
137
+ )
138
+ end
139
+
140
+ if formats.empty?
141
+ append_hls_formats(payload, formats, language)
142
+ end
143
+
144
+ formats
145
+ end
146
+
147
+ private def append_hls_formats(
148
+ payload : Hash(String, JSON::Any),
149
+ formats : Array(Format),
150
+ language : String?,
151
+ ) : Nil
152
+ playlists = payload["streamingPlaylists"]?.try(&.as_a?) || [] of JSON::Any
153
+
154
+ playlists.each_with_index do |playlist, index|
155
+ playlist_hash = playlist.as_h?
156
+ next unless playlist_hash
157
+
158
+ playlist_url = HttpUrl.absolute(JsonValue.string(playlist_hash["playlistUrl"]?))
159
+ next unless playlist_url
160
+
161
+ formats << Format.new(
162
+ format_id: playlists.size == 1 ? "hls" : "hls-#{index + 1}",
163
+ url: playlist_url,
164
+ kind: FormatKind::Combined,
165
+ protocol: FormatProtocol::Hls,
166
+ language: language,
167
+ note: "HLS",
168
+ )
169
+ end
170
+ end
171
+
172
+ private def uploader(payload : Hash(String, JSON::Any)) : String?
173
+ account = payload["account"]?.try(&.as_h?)
174
+ return nil unless account
175
+
176
+ JsonValue.string(account["displayName"]?) || JsonValue.string(account["name"]?)
177
+ end
178
+
179
+ private def thumbnail_url(url : URI, payload : Hash(String, JSON::Any)) : URI?
180
+ path = JsonValue.string(payload["thumbnailPath"]?) || JsonValue.string(payload["previewPath"]?)
181
+ return nil unless path
182
+ return nil unless path.starts_with?('/')
183
+
184
+ port = url.port
185
+ host = port ? "#{url.host}:#{port}" : url.host.to_s
186
+ HttpUrl.absolute("#{url.scheme}://#{host}#{path}")
187
+ end
188
+
189
+ private def language_for(payload : Hash(String, JSON::Any)) : String?
190
+ value = payload["language"]?
191
+ return nil unless value
192
+
193
+ if hash = value.as_h?
194
+ JsonValue.string(hash["id"]?)
195
+ else
196
+ value.as_s?
197
+ end
198
+ end
199
+
200
+ private def date_value(payload : Hash(String, JSON::Any)) : String?
201
+ value = JsonValue.string(payload["publishedAt"]?)
202
+ return nil unless value
203
+ return nil if value.size < 10
204
+
205
+ value[0, 10]
206
+ end
207
+
208
+ private def container_for(url : URI) : String?
209
+ extension = File.extname(url.path).lchop('.').downcase
210
+ extension.empty? ? nil : extension
211
+ end
212
+
213
+ private def bitrate(size : Int64?, duration : Float64?) : Int64?
214
+ return nil unless size && duration && duration > 0
215
+
216
+ (size * 8 / duration).to_i64
217
+ end
218
+
219
+ private def integer(value : JSON::Any?) : Int64?
220
+ parsed = JsonValue.integer(value)
221
+ return nil unless parsed && parsed >= 0
222
+
223
+ parsed
224
+ end
225
+ end
226
+ end