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,553 @@
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 "../playlist"
11
+
12
+ module Rill::Extractors
13
+ class SoundCloud < Extractor
14
+ DEFAULT_API_BASE = "https://api-v2.soundcloud.com"
15
+ TRACK_PATH = %r{\A/([^/]+)/([^/]+)/?\z}
16
+ SET_PATH = %r{\A/([^/]+)/sets/([^/]+)/?\z}
17
+ SHORT_PATH = %r{\A/([^/]+)/?\z}
18
+ HYDRATION = /window\.__sc_hydration\s*=\s*(\[.*?\]);/m
19
+ ISO_DATE = /\A\d{4}-\d{2}-\d{2}/
20
+
21
+ RESERVED_SEGMENTS = %w(
22
+ albums artists charts comments creators discover download followers
23
+ following jobs likes pages playlists popular recommended reposts
24
+ search sets stream tracks upload you
25
+ )
26
+
27
+ HLS_PRESETS = {
28
+ "aac_160k" => 30,
29
+ "abr_sq" => 20,
30
+ "aac_96k" => 10,
31
+ }
32
+
33
+ def initialize(@api_base : String = DEFAULT_API_BASE)
34
+ @api_base = @api_base.rstrip('/')
35
+ end
36
+
37
+ def name : String
38
+ "soundcloud"
39
+ end
40
+
41
+ def matches?(url : URI) : Bool
42
+ return false unless http_url?(url)
43
+
44
+ case url.host.try(&.downcase)
45
+ when "on.soundcloud.com"
46
+ !SHORT_PATH.match(url.path).nil?
47
+ when "soundcloud.com", "www.soundcloud.com"
48
+ track_path?(url.path) || set_path?(url.path)
49
+ else
50
+ false
51
+ end
52
+ end
53
+
54
+ def extract(url : URI, client : HTTPClient) : Media | Playlist
55
+ response = fetch_page(client, url)
56
+ page_url = response.url
57
+
58
+ if set_path?(page_url.path)
59
+ return extract_set(page_url, response.body, client)
60
+ end
61
+
62
+ unless track_path?(page_url.path)
63
+ raise MediaUnavailableError.new(page_url.to_s, "link did not resolve to a track or set")
64
+ end
65
+
66
+ entries = hydration_entries(response.body, page_url)
67
+ sound = hydration_data(entries, "sound", page_url)
68
+ title = JsonValue.string(sound["title"]?)
69
+ unless title
70
+ raise MediaUnavailableError.new(page_url.to_s, "track title is missing")
71
+ end
72
+
73
+ duration = duration_seconds(sound)
74
+ format = media_format(client, sound, entries, page_url, duration)
75
+
76
+ Media.new(
77
+ id: track_id(sound),
78
+ title: title,
79
+ description: JsonValue.string(sound["description"]?),
80
+ webpage_url: page_url,
81
+ uploader: uploader(sound),
82
+ duration_seconds: duration,
83
+ thumbnail_url: thumbnail_url(sound),
84
+ upload_date: upload_date(sound),
85
+ extractor: name,
86
+ formats: [format],
87
+ )
88
+ end
89
+
90
+ private def http_url?(url : URI) : Bool
91
+ url.scheme == "http" || url.scheme == "https"
92
+ end
93
+
94
+ private def track_path?(path : String) : Bool
95
+ match = TRACK_PATH.match(path)
96
+ return false unless match
97
+
98
+ user = match[1]
99
+ slug = match[2]
100
+ return false if user.empty? || slug.empty?
101
+ return false if reserved?(user) || reserved?(slug)
102
+
103
+ true
104
+ end
105
+
106
+ private def set_path?(path : String) : Bool
107
+ match = SET_PATH.match(path)
108
+ return false unless match
109
+
110
+ user = match[1]
111
+ slug = match[2]
112
+ return false if user.empty? || slug.empty?
113
+ return false if reserved?(user) || reserved?(slug)
114
+
115
+ true
116
+ end
117
+
118
+ private def reserved?(segment : String) : Bool
119
+ RESERVED_SEGMENTS.includes?(segment.downcase)
120
+ end
121
+
122
+ private def fetch_page(client : HTTPClient, url : URI) : HTTPClient::Response
123
+ client.get(url)
124
+ rescue ex : HTTPError
125
+ if ex.status.code == 404
126
+ raise MediaUnavailableError.new(url.to_s, "page not found")
127
+ end
128
+
129
+ raise ex
130
+ end
131
+
132
+ private def hydration_entries(body : String, url : URI) : Array(JSON::Any)
133
+ match = HYDRATION.match(body)
134
+ unless match
135
+ raise MediaUnavailableError.new(url.to_s, "page has no hydration data")
136
+ end
137
+
138
+ entries = JSON.parse(match[1]).as_a?
139
+ unless entries
140
+ raise MediaUnavailableError.new(url.to_s, "hydration data is not a JSON array")
141
+ end
142
+
143
+ entries
144
+ rescue JSON::ParseException
145
+ raise MediaUnavailableError.new(url.to_s, "hydration data is not valid JSON")
146
+ end
147
+
148
+ private def hydration_data(
149
+ entries : Array(JSON::Any),
150
+ hydratable : String,
151
+ url : URI,
152
+ ) : Hash(String, JSON::Any)
153
+ data = hydration_data?(entries, hydratable)
154
+ unless data
155
+ raise MediaUnavailableError.new(url.to_s, "#{hydratable} data is missing")
156
+ end
157
+
158
+ data
159
+ end
160
+
161
+ private def hydration_data?(
162
+ entries : Array(JSON::Any),
163
+ hydratable : String,
164
+ ) : Hash(String, JSON::Any)?
165
+ entries.each do |entry|
166
+ hash = entry.as_h?
167
+ next unless hash
168
+ next unless JsonValue.string(hash["hydratable"]?) == hydratable
169
+
170
+ return hash["data"]?.try(&.as_h?)
171
+ end
172
+
173
+ nil
174
+ end
175
+
176
+ private def extract_set(page_url : URI, body : String, client : HTTPClient) : Playlist
177
+ hydration = hydration_entries(body, page_url)
178
+ playlist = hydration_data(hydration, "playlist", page_url)
179
+ title = JsonValue.string(playlist["title"]?)
180
+ unless title
181
+ raise MediaUnavailableError.new(page_url.to_s, "set title is missing")
182
+ end
183
+
184
+ tracks = complete_tracks(client, hydration, playlist, page_url)
185
+ if tracks.empty?
186
+ raise MediaUnavailableError.new(page_url.to_s, "set has no tracks")
187
+ end
188
+
189
+ entries = playlist_entries(tracks, page_url)
190
+ if entries.empty?
191
+ raise MediaUnavailableError.new(page_url.to_s, "set has no usable track URLs")
192
+ end
193
+
194
+ Playlist.new(
195
+ id: JsonValue.string(playlist["id"]?),
196
+ title: title,
197
+ description: JsonValue.string(playlist["description"]?),
198
+ webpage_url: page_url,
199
+ extractor: name,
200
+ entries: entries,
201
+ )
202
+ end
203
+
204
+ private def complete_tracks(
205
+ client : HTTPClient,
206
+ hydration : Array(JSON::Any),
207
+ playlist : Hash(String, JSON::Any),
208
+ page_url : URI,
209
+ ) : Array(JSON::Any)
210
+ tracks = tracks_from(playlist)
211
+ total = JsonValue.integer(playlist["track_count"]?)
212
+ if total && tracks.size < total
213
+ if expanded = playlist_tracks(client, hydration, playlist)
214
+ tracks = expanded
215
+ end
216
+ end
217
+
218
+ fill_tracks(client, hydration, playlist, tracks)
219
+ end
220
+
221
+ private def tracks_from(playlist : Hash(String, JSON::Any)) : Array(JSON::Any)
222
+ if value = playlist["tracks"]?
223
+ if list = value.as_a?
224
+ return list
225
+ end
226
+ end
227
+
228
+ [] of JSON::Any
229
+ end
230
+
231
+ private def playlist_tracks(
232
+ client : HTTPClient,
233
+ hydration : Array(JSON::Any),
234
+ playlist : Hash(String, JSON::Any),
235
+ ) : Array(JSON::Any)?
236
+ id = JsonValue.string(playlist["id"]?)
237
+ client_id = api_client_id(hydration)
238
+ return nil unless id && client_id
239
+
240
+ endpoint = URI.parse("#{@api_base}/playlists/#{URI.encode_path_segment(id)}")
241
+ endpoint.query = URI::Params.encode({"client_id" => client_id})
242
+ payload = JSON.parse(client.get(endpoint).body)
243
+ payload["tracks"]?.try(&.as_a?)
244
+ rescue Rill::Error | JSON::ParseException
245
+ nil
246
+ end
247
+
248
+ private def fill_tracks(
249
+ client : HTTPClient,
250
+ hydration : Array(JSON::Any),
251
+ playlist : Hash(String, JSON::Any),
252
+ tracks : Array(JSON::Any),
253
+ ) : Array(JSON::Any)
254
+ missing = {} of Int32 => String
255
+ tracks.each_with_index do |value, index|
256
+ track = value.as_h?
257
+ next unless track
258
+ next if JsonValue.string(track["permalink_url"]?)
259
+
260
+ id = JsonValue.string(track["id"]?)
261
+ missing[index] = id if id
262
+ end
263
+ return tracks if missing.empty?
264
+
265
+ client_id = api_client_id(hydration)
266
+ playlist_id = JsonValue.string(playlist["id"]?)
267
+ return tracks unless client_id && playlist_id
268
+
269
+ complete = tracks.dup
270
+ missing.values.each_slice(50) do |ids|
271
+ fetched = fetch_tracks(client, ids, client_id, playlist_id)
272
+ next unless fetched
273
+
274
+ by_id = {} of String => JSON::Any
275
+ fetched.each do |value|
276
+ track = value.as_h?
277
+ next unless track
278
+
279
+ id = JsonValue.string(track["id"]?)
280
+ by_id[id] = value if id
281
+ end
282
+
283
+ missing.each do |index, id|
284
+ if replacement = by_id[id]?
285
+ complete[index] = replacement
286
+ end
287
+ end
288
+ end
289
+
290
+ complete
291
+ end
292
+
293
+ private def fetch_tracks(
294
+ client : HTTPClient,
295
+ ids : Array(String),
296
+ client_id : String,
297
+ playlist_id : String,
298
+ ) : Array(JSON::Any)?
299
+ endpoint = URI.parse("#{@api_base}/tracks")
300
+ endpoint.query = URI::Params.encode({
301
+ "ids" => ids.join(","),
302
+ "client_id" => client_id,
303
+ "playlistId" => playlist_id,
304
+ })
305
+ JSON.parse(client.get(endpoint).body).as_a?
306
+ rescue Rill::Error | JSON::ParseException
307
+ nil
308
+ end
309
+
310
+ private def playlist_entries(
311
+ tracks : Array(JSON::Any),
312
+ page_url : URI,
313
+ ) : Array(PlaylistEntry)
314
+ entries = [] of PlaylistEntry
315
+
316
+ tracks.each do |item|
317
+ track = item.as_h?
318
+ next unless track
319
+
320
+ permalink = JsonValue.string(track["permalink_url"]?)
321
+ next unless permalink
322
+
323
+ entry_url = HttpUrl.resolve(page_url, permalink)
324
+ next unless entry_url
325
+
326
+ entries << PlaylistEntry.new(
327
+ url: entry_url,
328
+ title: JsonValue.string(track["title"]?),
329
+ id: JsonValue.string(track["id"]?),
330
+ )
331
+ end
332
+
333
+ entries
334
+ end
335
+
336
+ private def media_format(
337
+ client : HTTPClient,
338
+ sound : Hash(String, JSON::Any),
339
+ entries : Array(JSON::Any),
340
+ url : URI,
341
+ duration : Float64?,
342
+ ) : Format
343
+ transcodings = transcodings(sound, url)
344
+
345
+ if progressive = progressive_transcoding(transcodings)
346
+ return progressive_format(progressive, url, duration)
347
+ end
348
+
349
+ hls = best_hls_transcoding(transcodings)
350
+ unless hls
351
+ raise MediaUnavailableError.new(url.to_s, "track has no progressive or HLS stream")
352
+ end
353
+
354
+ hls_format(client, hls, entries, sound, url, duration)
355
+ end
356
+
357
+ private def transcodings(
358
+ sound : Hash(String, JSON::Any),
359
+ url : URI,
360
+ ) : Array(Hash(String, JSON::Any))
361
+ media = sound["media"]?.try(&.as_h?)
362
+ values = media.try { |hash| hash["transcodings"]?.try(&.as_a?) }
363
+ items = values.try(&.compact_map(&.as_h?)) || [] of Hash(String, JSON::Any)
364
+ if items.empty?
365
+ raise MediaUnavailableError.new(url.to_s, "track has no transcodings")
366
+ end
367
+
368
+ items
369
+ end
370
+
371
+ private def progressive_transcoding(
372
+ transcodings : Array(Hash(String, JSON::Any)),
373
+ ) : Hash(String, JSON::Any)?
374
+ candidates = transcodings.select do |item|
375
+ protocol(item) == "progressive" && !transcoding_url(item).nil? && !transcoding_preset(item).nil?
376
+ end
377
+
378
+ candidates.max_by? do |item|
379
+ preset_bitrate(transcoding_preset(item).not_nil!) || 0
380
+ end
381
+ end
382
+
383
+ private def best_hls_transcoding(
384
+ transcodings : Array(Hash(String, JSON::Any)),
385
+ ) : Hash(String, JSON::Any)?
386
+ candidates = transcodings.select do |item|
387
+ hls?(item) && !transcoding_url(item).nil? && !transcoding_preset(item).nil?
388
+ end
389
+
390
+ candidates.max_by? do |item|
391
+ HLS_PRESETS[transcoding_preset(item).not_nil!]? || 0
392
+ end
393
+ end
394
+
395
+ private def hls?(transcoding : Hash(String, JSON::Any)) : Bool
396
+ value = protocol(transcoding)
397
+ return true if value == "hls"
398
+ return false if value.try(&.includes?("encrypted"))
399
+
400
+ transcoding_url(transcoding).try(&.includes?("/hls")) || false
401
+ end
402
+
403
+ private def protocol(transcoding : Hash(String, JSON::Any)) : String?
404
+ format = transcoding["format"]?.try(&.as_h?)
405
+ format.try { |hash| JsonValue.string(hash["protocol"]?) }.try(&.downcase)
406
+ end
407
+
408
+ private def transcoding_url(transcoding : Hash(String, JSON::Any)) : String?
409
+ JsonValue.string(transcoding["url"]?)
410
+ end
411
+
412
+ private def transcoding_preset(transcoding : Hash(String, JSON::Any)) : String?
413
+ JsonValue.string(transcoding["preset"]?)
414
+ end
415
+
416
+ private def progressive_format(
417
+ transcoding : Hash(String, JSON::Any),
418
+ url : URI,
419
+ duration : Float64?,
420
+ ) : Format
421
+ preset = transcoding_preset(transcoding).not_nil!
422
+ stream_url = HttpUrl.absolute(transcoding_url(transcoding))
423
+ unless stream_url
424
+ raise MediaUnavailableError.new(url.to_s, "progressive stream URL is invalid")
425
+ end
426
+
427
+ Format.new(
428
+ format_id: "progressive-#{preset}",
429
+ url: stream_url,
430
+ kind: FormatKind::Audio,
431
+ container: "mp3",
432
+ bitrate: preset_bitrate(preset),
433
+ duration_seconds: duration,
434
+ note: "progressive",
435
+ )
436
+ end
437
+
438
+ private def hls_format(
439
+ client : HTTPClient,
440
+ transcoding : Hash(String, JSON::Any),
441
+ entries : Array(JSON::Any),
442
+ sound : Hash(String, JSON::Any),
443
+ url : URI,
444
+ duration : Float64?,
445
+ ) : Format
446
+ preset = transcoding_preset(transcoding).not_nil!
447
+ endpoint = HttpUrl.absolute(transcoding_url(transcoding))
448
+ unless endpoint
449
+ raise MediaUnavailableError.new(url.to_s, "HLS stream URL is invalid")
450
+ end
451
+
452
+ client_id = api_client_id(entries)
453
+ unless client_id
454
+ raise MediaUnavailableError.new(url.to_s, "client id is missing from the page")
455
+ end
456
+
457
+ query = URI::Params.parse(endpoint.query || "")
458
+ query["client_id"] = client_id
459
+ if authorization = JsonValue.string(sound["track_authorization"]?)
460
+ query["track_authorization"] = authorization
461
+ end
462
+ endpoint.query = query.to_s
463
+
464
+ stream_url = resolve_stream_url(client, endpoint, url)
465
+ Format.new(
466
+ format_id: "hls-#{preset}",
467
+ url: stream_url,
468
+ kind: FormatKind::Audio,
469
+ protocol: FormatProtocol::Hls,
470
+ container: "m4a",
471
+ bitrate: preset_bitrate(preset),
472
+ duration_seconds: duration,
473
+ note: "HLS",
474
+ )
475
+ end
476
+
477
+ private def api_client_id(entries : Array(JSON::Any)) : String?
478
+ client = hydration_data?(entries, "apiClient")
479
+ client.try { |hash| JsonValue.string(hash["id"]?) }
480
+ end
481
+
482
+ private def resolve_stream_url(client : HTTPClient, endpoint : URI, url : URI) : URI
483
+ response = client.get(endpoint)
484
+ payload = JSON.parse(response.body).as_h?
485
+ value = payload.try { |hash| JsonValue.string(hash["url"]?) }
486
+ stream_url = HttpUrl.absolute(value)
487
+ unless stream_url
488
+ raise MediaUnavailableError.new(url.to_s, "HLS stream is unavailable")
489
+ end
490
+
491
+ stream_url
492
+ rescue JSON::ParseException
493
+ raise MediaUnavailableError.new(url.to_s, "HLS stream response is not valid JSON")
494
+ end
495
+
496
+ private def duration_seconds(sound : Hash(String, JSON::Any)) : Float64?
497
+ milliseconds = JsonValue.number(sound["duration"]?)
498
+ return nil unless milliseconds && milliseconds > 0
499
+
500
+ milliseconds / 1000.0
501
+ end
502
+
503
+ private def uploader(sound : Hash(String, JSON::Any)) : String?
504
+ user = sound["user"]?.try(&.as_h?)
505
+ return nil unless user
506
+
507
+ JsonValue.string(user["username"]?) || JsonValue.string(user["permalink"]?)
508
+ end
509
+
510
+ private def thumbnail_url(sound : Hash(String, JSON::Any)) : URI?
511
+ value = JsonValue.string(sound["artwork_url"]?)
512
+ return nil unless value
513
+
514
+ value = value.sub("-large", "-t500x500") if value.includes?("-large")
515
+ HttpUrl.absolute(value)
516
+ end
517
+
518
+ private def upload_date(sound : Hash(String, JSON::Any)) : String?
519
+ values = [
520
+ JsonValue.string(sound["display_date"]?),
521
+ JsonValue.string(sound["created_at"]?),
522
+ ]
523
+
524
+ values.each do |value|
525
+ next unless value
526
+ next unless ISO_DATE.match(value)
527
+
528
+ return value[0, 10]
529
+ end
530
+
531
+ nil
532
+ end
533
+
534
+ private def track_id(sound : Hash(String, JSON::Any)) : String?
535
+ JsonValue.string(sound["id"]?) || urn_id(JsonValue.string(sound["urn"]?))
536
+ end
537
+
538
+ private def urn_id(urn : String?) : String?
539
+ return nil unless urn
540
+
541
+ id = urn.split(':').last?
542
+ id && !id.empty? ? id : nil
543
+ end
544
+
545
+ private def preset_bitrate(preset : String) : Int64?
546
+ match = /(\d+)k/.match(preset) || /_(\d+)\z/.match(preset)
547
+ return nil unless match
548
+
549
+ value = match[1].to_i64?
550
+ value ? value * 1000 : nil
551
+ end
552
+ end
553
+ end