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