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.
- 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,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
|