rill-node 0.0.0-stage → 2026.11.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +64 -2
- package/binding.gyp +31 -0
- package/index.d.ts +45 -0
- package/index.js +112 -0
- package/native/darwin-arm64/librill.dylib +0 -0
- package/native/darwin-arm64/rill-media +0 -0
- package/native/darwin-arm64/rill_node.node +0 -0
- package/native/darwin-x64/librill.dylib +0 -0
- package/native/darwin-x64/rill-media +0 -0
- package/native/darwin-x64/rill_node.node +0 -0
- package/native/linux-arm64-gnu/librill.so +0 -0
- package/native/linux-arm64-gnu/rill-media +0 -0
- package/native/linux-arm64-gnu/rill_node.node +0 -0
- package/native/linux-x64-gnu/librill.so +0 -0
- package/native/linux-x64-gnu/rill-media +0 -0
- package/native/linux-x64-gnu/rill_node.node +0 -0
- package/native-src/include/rill.h +90 -0
- package/native-src/include/rill_media.h +22 -0
- package/native-src/shard.yml +9 -0
- package/native-src/src/c_api.cr +194 -0
- package/native-src/src/cli.cr +637 -0
- package/native-src/src/rill/c_api/job.cr +336 -0
- package/native-src/src/rill/c_api/memory.cr +32 -0
- package/native-src/src/rill/c_api/status.cr +25 -0
- package/native-src/src/rill/c_api/sync.cr +125 -0
- package/native-src/src/rill/c_api/worker_pool.cr +403 -0
- package/native-src/src/rill/cancellation.cr +29 -0
- package/native-src/src/rill/client.cr +52 -0
- package/native-src/src/rill/cookie_jar.cr +130 -0
- package/native-src/src/rill/dash/manifest.cr +279 -0
- package/native-src/src/rill/dash/parser.cr +227 -0
- package/native-src/src/rill/download_options.cr +96 -0
- package/native-src/src/rill/download_plan.cr +27 -0
- package/native-src/src/rill/download_progress.cr +16 -0
- package/native-src/src/rill/downloader.cr +662 -0
- package/native-src/src/rill/errors.cr +155 -0
- package/native-src/src/rill/extractor.cr +18 -0
- package/native-src/src/rill/extractor_registry.cr +98 -0
- package/native-src/src/rill/extractors/bandcamp.cr +265 -0
- package/native-src/src/rill/extractors/dash.cr +77 -0
- package/native-src/src/rill/extractors/direct_media.cr +73 -0
- package/native-src/src/rill/extractors/generic.cr +441 -0
- package/native-src/src/rill/extractors/hls.cr +202 -0
- package/native-src/src/rill/extractors/internet_archive.cr +180 -0
- package/native-src/src/rill/extractors/m3u.cr +81 -0
- package/native-src/src/rill/extractors/media_ccc_de.cr +219 -0
- package/native-src/src/rill/extractors/mixcloud.cr +495 -0
- package/native-src/src/rill/extractors/peer_tube.cr +226 -0
- package/native-src/src/rill/extractors/rumble.cr +325 -0
- package/native-src/src/rill/extractors/soundcloud.cr +553 -0
- package/native-src/src/rill/extractors/spotify.cr +184 -0
- package/native-src/src/rill/extractors/twitch_clip.cr +273 -0
- package/native-src/src/rill/extractors/youtube.cr +520 -0
- package/native-src/src/rill/format.cr +82 -0
- package/native-src/src/rill/format_selector.cr +143 -0
- package/native-src/src/rill/hls/parser.cr +257 -0
- package/native-src/src/rill/hls/playlist.cr +100 -0
- package/native-src/src/rill/http_client.cr +503 -0
- package/native-src/src/rill/http_url.cr +28 -0
- package/native-src/src/rill/json_value.cr +66 -0
- package/native-src/src/rill/media.cr +151 -0
- package/native-src/src/rill/media_muxer.cr +333 -0
- package/native-src/src/rill/mime_type.cr +58 -0
- package/native-src/src/rill/output_file_name.cr +91 -0
- package/native-src/src/rill/playlist.cr +109 -0
- package/native-src/src/rill/request_options.cr +179 -0
- package/native-src/src/rill/segment_assembly.cr +70 -0
- package/native-src/src/rill/timestamp.cr +34 -0
- package/native-src/src/rill/version.cr +3 -0
- package/native-src/src/rill.cr +47 -0
- package/package.json +48 -4
- package/scripts/build-native.js +46 -0
- package/scripts/install-gyp.js +10 -0
- package/scripts/install.js +19 -0
- package/scripts/lib.js +1 -0
- package/scripts/libdir.js +23 -0
- package/scripts/package-native.js +35 -0
- package/scripts/platform.js +41 -0
- package/scripts/prepack.js +47 -0
- package/src/addon.c +498 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
require "../errors"
|
|
5
|
+
require "../extractor"
|
|
6
|
+
require "../http_url"
|
|
7
|
+
require "../json_value"
|
|
8
|
+
require "../media"
|
|
9
|
+
require "./youtube"
|
|
10
|
+
|
|
11
|
+
module Rill::Extractors
|
|
12
|
+
class Spotify < Extractor
|
|
13
|
+
DEFAULT_API_BASE = "https://open.spotify.com"
|
|
14
|
+
TRACK_PATH = %r{\A/(?:intl-[a-z-]+/)?track/([A-Za-z0-9]{22})/?\z}
|
|
15
|
+
USER_AGENT = "Mozilla/5.0"
|
|
16
|
+
|
|
17
|
+
def initialize(
|
|
18
|
+
@api_base : String = DEFAULT_API_BASE,
|
|
19
|
+
@youtube : YouTube = YouTube.new,
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def name : String
|
|
24
|
+
"spotify"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def matches?(url : URI) : Bool
|
|
28
|
+
return false unless url.scheme == "http" || url.scheme == "https"
|
|
29
|
+
|
|
30
|
+
host = url.host.try(&.downcase)
|
|
31
|
+
return false unless host == "open.spotify.com" || host == "www.open.spotify.com"
|
|
32
|
+
|
|
33
|
+
!track_id_for(url).nil?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def extract(url : URI, client : HTTPClient) : Media
|
|
37
|
+
track_id = track_id_for(url)
|
|
38
|
+
unless track_id
|
|
39
|
+
raise UnsupportedUrlError.new(url.to_s)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
entity = fetch_entity(client, track_id, url)
|
|
43
|
+
if entity["isPlayable"]?.try(&.as_bool?) == false
|
|
44
|
+
raise MediaUnavailableError.new(url.to_s, "track is not playable")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
title = JsonValue.string(entity["title"]?) || JsonValue.string(entity["name"]?) || track_id
|
|
48
|
+
artists = artist_names(entity)
|
|
49
|
+
duration = duration_seconds(entity)
|
|
50
|
+
query = [title, artists].compact.join(" ").strip
|
|
51
|
+
|
|
52
|
+
matches = @youtube.search(query, client, 10)
|
|
53
|
+
if matches.empty?
|
|
54
|
+
raise MediaUnavailableError.new(url.to_s, "could not find a YouTube match")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
match = select_match(matches, duration)
|
|
58
|
+
youtube_media = @youtube.extract(
|
|
59
|
+
URI.parse("https://www.youtube.com/watch?v=#{match.video_id}"),
|
|
60
|
+
client,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
warnings = [
|
|
64
|
+
"Spotify provides no DRM-free stream; formats were resolved from YouTube: " \
|
|
65
|
+
"#{match.title} (https://youtu.be/#{match.video_id}).",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
if duration && match.duration_seconds
|
|
69
|
+
difference = (duration - match.duration_seconds.not_nil!).abs
|
|
70
|
+
if difference > 15
|
|
71
|
+
warnings << "The matched YouTube duration differs by #{difference.round.to_i} seconds; verify the result."
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
Media.new(
|
|
76
|
+
id: track_id,
|
|
77
|
+
title: title,
|
|
78
|
+
webpage_url: url,
|
|
79
|
+
uploader: artists,
|
|
80
|
+
duration_seconds: duration,
|
|
81
|
+
thumbnail_url: thumbnail_url(entity),
|
|
82
|
+
upload_date: release_date(entity),
|
|
83
|
+
extractor: name,
|
|
84
|
+
formats: youtube_media.formats,
|
|
85
|
+
warnings: warnings,
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private def fetch_entity(client : HTTPClient, track_id : String, url : URI) : Hash(String, JSON::Any)
|
|
90
|
+
headers = HTTP::Headers.new
|
|
91
|
+
headers["User-Agent"] = USER_AGENT
|
|
92
|
+
headers["Accept-Language"] = "en-US,en;q=0.9"
|
|
93
|
+
|
|
94
|
+
response = client.get("#{@api_base}/embed/track/#{track_id}", headers)
|
|
95
|
+
match = /<script id="__NEXT_DATA__" type="application\/json">(.*?)<\/script>/m.match(response.body)
|
|
96
|
+
unless match
|
|
97
|
+
raise MediaUnavailableError.new(url.to_s, "Spotify embed data is missing")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
payload = JSON.parse(match[1])
|
|
101
|
+
entity = dig(payload, ["props", "pageProps", "state", "data", "entity"]).try(&.as_h?)
|
|
102
|
+
unless entity
|
|
103
|
+
raise MediaUnavailableError.new(url.to_s, "Spotify track data is missing")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
entity
|
|
107
|
+
rescue JSON::ParseException
|
|
108
|
+
raise MediaUnavailableError.new(url.to_s, "Spotify embed data is not valid JSON")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private def track_id_for(url : URI) : String?
|
|
112
|
+
match = TRACK_PATH.match(url.path)
|
|
113
|
+
return nil unless match
|
|
114
|
+
|
|
115
|
+
match[1]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private def artist_names(entity : Hash(String, JSON::Any)) : String?
|
|
119
|
+
artists = entity["artists"]?.try(&.as_a?)
|
|
120
|
+
return nil unless artists
|
|
121
|
+
|
|
122
|
+
names = artists.compact_map do |artist|
|
|
123
|
+
artist.as_h?.try { |hash| JsonValue.string(hash["name"]?) }
|
|
124
|
+
end
|
|
125
|
+
names.empty? ? nil : names.join(", ")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private def duration_seconds(entity : Hash(String, JSON::Any)) : Float64?
|
|
129
|
+
milliseconds = entity["duration"]?.try(&.as_i64?)
|
|
130
|
+
return nil unless milliseconds && milliseconds > 0
|
|
131
|
+
|
|
132
|
+
milliseconds / 1000.0
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private def release_date(entity : Hash(String, JSON::Any)) : String?
|
|
136
|
+
release = entity["releaseDate"]?.try(&.as_h?)
|
|
137
|
+
value = release.try { |hash| JsonValue.string(hash["isoString"]?) }
|
|
138
|
+
return nil unless value
|
|
139
|
+
return nil if value.size < 10
|
|
140
|
+
|
|
141
|
+
value[0, 10]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private def thumbnail_url(entity : Hash(String, JSON::Any)) : URI?
|
|
145
|
+
identity = entity["visualIdentity"]?.try(&.as_h?)
|
|
146
|
+
images = identity.try { |hash| hash["image"]?.try(&.as_a?) }
|
|
147
|
+
return nil unless images
|
|
148
|
+
|
|
149
|
+
image = images.max_by? do |item|
|
|
150
|
+
item.as_h?.try { |hash| hash["maxWidth"]?.try(&.as_i64?) } || 0_i64
|
|
151
|
+
end
|
|
152
|
+
return nil unless image
|
|
153
|
+
|
|
154
|
+
value = image.as_h?.try { |hash| JsonValue.string(hash["url"]?) }
|
|
155
|
+
HttpUrl.absolute(value)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private def select_match(
|
|
159
|
+
matches : Array(YouTube::SearchResult),
|
|
160
|
+
duration : Float64?,
|
|
161
|
+
) : YouTube::SearchResult
|
|
162
|
+
return matches.first unless duration
|
|
163
|
+
|
|
164
|
+
matches.min_by? do |match|
|
|
165
|
+
if match_duration = match.duration_seconds
|
|
166
|
+
(match_duration - duration).abs
|
|
167
|
+
else
|
|
168
|
+
Float64::MAX
|
|
169
|
+
end
|
|
170
|
+
end || matches.first
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private def dig(value : JSON::Any?, keys : Array(String)) : JSON::Any?
|
|
174
|
+
keys.each do |key|
|
|
175
|
+
hash = value.try(&.as_h?)
|
|
176
|
+
return nil unless hash
|
|
177
|
+
|
|
178
|
+
value = hash[key]?
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
value
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
require "http/params"
|
|
2
|
+
require "json"
|
|
3
|
+
require "set"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
require "../errors"
|
|
7
|
+
require "../extractor"
|
|
8
|
+
require "../format"
|
|
9
|
+
require "../http_url"
|
|
10
|
+
require "../json_value"
|
|
11
|
+
require "../media"
|
|
12
|
+
|
|
13
|
+
module Rill::Extractors
|
|
14
|
+
class TwitchClip < Extractor
|
|
15
|
+
DEFAULT_ENDPOINT = "https://gql.twitch.tv/gql"
|
|
16
|
+
CLIENT_ID = "ue6666qo983tsx6so1t0vnawi233wa"
|
|
17
|
+
OPERATION_HASH = "2db6a3b20eabf510bd3cf465ae2408834b59eb6b8af89ca73ab1486cacecfb63"
|
|
18
|
+
SLUG_PATTERN = /\A[A-Za-z0-9_-]+\z/
|
|
19
|
+
CLIP_PATH = %r{\A/([A-Za-z0-9_-]+)/?\z}
|
|
20
|
+
CHANNEL_CLIP_PATH = %r{\A/[^/]+/clip/([A-Za-z0-9_-]+)/?\z}
|
|
21
|
+
|
|
22
|
+
def initialize(@endpoint : String = DEFAULT_ENDPOINT)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def name : String
|
|
26
|
+
"twitch_clip"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def matches?(url : URI) : Bool
|
|
30
|
+
return false unless url.scheme == "http" || url.scheme == "https"
|
|
31
|
+
|
|
32
|
+
!identifier_for(url).nil?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def extract(url : URI, client : HTTPClient) : Media
|
|
36
|
+
slug = identifier_for(url)
|
|
37
|
+
unless slug
|
|
38
|
+
raise UnsupportedUrlError.new(url.to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
clip = clip_data(slug, client, url)
|
|
42
|
+
formats = formats_from(clip)
|
|
43
|
+
|
|
44
|
+
if formats.empty?
|
|
45
|
+
raise MediaUnavailableError.new(url.to_s, "no playable formats were found")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Media.new(
|
|
49
|
+
id: JsonValue.string(clip["id"]?) || slug,
|
|
50
|
+
title: JsonValue.string(clip["title"]?) || slug,
|
|
51
|
+
webpage_url: url,
|
|
52
|
+
uploader: uploader_for(clip),
|
|
53
|
+
duration_seconds: JsonValue.seconds(clip["duration"]?),
|
|
54
|
+
thumbnail_url: thumbnail_for(clip),
|
|
55
|
+
upload_date: upload_date_for(clip),
|
|
56
|
+
extractor: name,
|
|
57
|
+
formats: formats,
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private def identifier_for(url : URI) : String?
|
|
62
|
+
if clips_host?(url.host)
|
|
63
|
+
clips_identifier(url)
|
|
64
|
+
elsif twitch_host?(url.host)
|
|
65
|
+
channel_identifier(url)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private def clips_identifier(url : URI) : String?
|
|
70
|
+
return embed_identifier(url) if url.path == "/embed"
|
|
71
|
+
|
|
72
|
+
slug = CLIP_PATH.match(url.path).try(&.[1])
|
|
73
|
+
return nil if slug == "embed"
|
|
74
|
+
|
|
75
|
+
slug
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private def embed_identifier(url : URI) : String?
|
|
79
|
+
slug = HTTP::Params.parse(url.query || "")["clip"]?
|
|
80
|
+
return nil unless slug
|
|
81
|
+
|
|
82
|
+
slug if SLUG_PATTERN.matches?(slug)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private def channel_identifier(url : URI) : String?
|
|
86
|
+
CHANNEL_CLIP_PATH.match(url.path).try(&.[1])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private def clips_host?(host : String?) : Bool
|
|
90
|
+
host.try(&.downcase) == "clips.twitch.tv"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private def twitch_host?(host : String?) : Bool
|
|
94
|
+
case host.try(&.downcase)
|
|
95
|
+
when "twitch.tv", "www.twitch.tv", "go.twitch.tv", "m.twitch.tv"
|
|
96
|
+
true
|
|
97
|
+
else
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private def clip_data(
|
|
103
|
+
slug : String,
|
|
104
|
+
client : HTTPClient,
|
|
105
|
+
url : URI,
|
|
106
|
+
) : Hash(String, JSON::Any)
|
|
107
|
+
response = client.post(@endpoint, request_body(slug), request_headers)
|
|
108
|
+
clip = parsed_clip(response.body)
|
|
109
|
+
|
|
110
|
+
unless clip
|
|
111
|
+
raise MediaUnavailableError.new(url.to_s, "clip is unavailable")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
clip
|
|
115
|
+
rescue JSON::ParseException
|
|
116
|
+
raise MediaUnavailableError.new(url.to_s, "clip response is not valid JSON")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private def request_headers : HTTP::Headers
|
|
120
|
+
headers = HTTP::Headers.new
|
|
121
|
+
headers["Client-ID"] = CLIENT_ID
|
|
122
|
+
headers["Content-Type"] = "text/plain;charset=UTF-8"
|
|
123
|
+
headers
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private def request_body(slug : String) : String
|
|
127
|
+
JSON.build do |json|
|
|
128
|
+
json.array do
|
|
129
|
+
json.object do
|
|
130
|
+
json.field "operationName", "ShareClipRenderStatus"
|
|
131
|
+
json.field "variables" do
|
|
132
|
+
json.object do
|
|
133
|
+
json.field "slug", slug
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
json.field "extensions" do
|
|
137
|
+
json.object do
|
|
138
|
+
json.field "persistedQuery" do
|
|
139
|
+
json.object do
|
|
140
|
+
json.field "version", 1
|
|
141
|
+
json.field "sha256Hash", OPERATION_HASH
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private def parsed_clip(body : String) : Hash(String, JSON::Any)?
|
|
152
|
+
payload = JSON.parse(body).as_a?
|
|
153
|
+
return nil unless payload
|
|
154
|
+
|
|
155
|
+
element = payload.first?.try(&.as_h?)
|
|
156
|
+
return nil unless element
|
|
157
|
+
|
|
158
|
+
data = element["data"]?.try(&.as_h?)
|
|
159
|
+
return nil unless data
|
|
160
|
+
|
|
161
|
+
data["clip"]?.try(&.as_h?)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private def formats_from(clip : Hash(String, JSON::Any)) : Array(Format)
|
|
165
|
+
token = clip["playbackAccessToken"]?.try(&.as_h?)
|
|
166
|
+
signature = JsonValue.string(token.try(&.["signature"]?))
|
|
167
|
+
value = JsonValue.string(token.try(&.["value"]?))
|
|
168
|
+
return [] of Format unless signature && value
|
|
169
|
+
|
|
170
|
+
formats = [] of Format
|
|
171
|
+
seen = Set(String).new
|
|
172
|
+
assets = clip["assets"]?.try(&.as_a?) || [] of JSON::Any
|
|
173
|
+
|
|
174
|
+
assets.each_with_index do |asset_value, index|
|
|
175
|
+
asset = asset_value.as_h?
|
|
176
|
+
next unless asset
|
|
177
|
+
|
|
178
|
+
append_asset(formats, seen, asset, index == 1, signature, value)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
formats
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private def append_asset(
|
|
185
|
+
formats : Array(Format),
|
|
186
|
+
seen : Set(String),
|
|
187
|
+
asset : Hash(String, JSON::Any),
|
|
188
|
+
portrait : Bool,
|
|
189
|
+
signature : String,
|
|
190
|
+
token : String,
|
|
191
|
+
) : Nil
|
|
192
|
+
qualities = asset["videoQualities"]?.try(&.as_a?) || [] of JSON::Any
|
|
193
|
+
|
|
194
|
+
qualities.each do |quality_value|
|
|
195
|
+
quality = quality_value.as_h?
|
|
196
|
+
next unless quality
|
|
197
|
+
|
|
198
|
+
source_url = JsonValue.string(quality["sourceURL"]?)
|
|
199
|
+
signed_url = sign_url(source_url, signature, token)
|
|
200
|
+
next unless signed_url
|
|
201
|
+
|
|
202
|
+
url_key = signed_url.to_s
|
|
203
|
+
next if seen.includes?(url_key)
|
|
204
|
+
|
|
205
|
+
quality_label = JsonValue.string(quality["quality"]?)
|
|
206
|
+
next unless quality_label
|
|
207
|
+
|
|
208
|
+
seen << url_key
|
|
209
|
+
formats << Format.new(
|
|
210
|
+
format_id: portrait ? "portrait-#{quality_label}" : quality_label,
|
|
211
|
+
url: signed_url,
|
|
212
|
+
kind: FormatKind::Combined,
|
|
213
|
+
protocol: FormatProtocol::Http,
|
|
214
|
+
container: "mp4",
|
|
215
|
+
height: height_for(quality_label),
|
|
216
|
+
fps: fps_for(quality),
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
private def sign_url(source_url : String?, signature : String, token : String) : URI?
|
|
222
|
+
url = HttpUrl.absolute(source_url)
|
|
223
|
+
return nil unless url
|
|
224
|
+
|
|
225
|
+
params = HTTP::Params.parse(url.query || "")
|
|
226
|
+
params["sig"] = signature
|
|
227
|
+
params["token"] = token
|
|
228
|
+
url.query = params.to_s
|
|
229
|
+
url
|
|
230
|
+
rescue URI::Error
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private def height_for(quality : String) : Int32?
|
|
235
|
+
height = quality.to_i?
|
|
236
|
+
height if height && height > 0
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
private def fps_for(quality : Hash(String, JSON::Any)) : Float64?
|
|
240
|
+
fps = JsonValue.number(quality["frameRate"]?)
|
|
241
|
+
fps if fps && fps > 0
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private def thumbnail_for(clip : Hash(String, JSON::Any)) : URI?
|
|
245
|
+
asset = clip["assets"]?.try(&.as_a?).try(&.first?).try(&.as_h?)
|
|
246
|
+
return nil unless asset
|
|
247
|
+
|
|
248
|
+
HttpUrl.absolute(JsonValue.string(asset["thumbnailURL"]?))
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
private def uploader_for(clip : Hash(String, JSON::Any)) : String?
|
|
252
|
+
curator = clip["curator"]?.try(&.as_h?)
|
|
253
|
+
uploader = presence(JsonValue.string(curator.try(&.["displayName"]?))) ||
|
|
254
|
+
presence(JsonValue.string(curator.try(&.["login"]?)))
|
|
255
|
+
return uploader if uploader
|
|
256
|
+
|
|
257
|
+
broadcaster = clip["broadcaster"]?.try(&.as_h?)
|
|
258
|
+
presence(JsonValue.string(broadcaster.try(&.["displayName"]?)))
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
private def presence(value : String?) : String?
|
|
262
|
+
value if value && !value.empty?
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
private def upload_date_for(clip : Hash(String, JSON::Any)) : String?
|
|
266
|
+
created_at = JsonValue.string(clip["createdAt"]?)
|
|
267
|
+
return nil unless created_at && created_at.size >= 10
|
|
268
|
+
|
|
269
|
+
date = created_at[0, 10]
|
|
270
|
+
date if /\A\d{4}-\d{2}-\d{2}\z/.matches?(date)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|