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,155 @@
|
|
|
1
|
+
require "http/status"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
module Rill
|
|
5
|
+
class Error < Exception
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class UnsupportedUrlError < Error
|
|
9
|
+
getter url : String
|
|
10
|
+
|
|
11
|
+
def initialize(@url : String)
|
|
12
|
+
super("unsupported URL: #{@url}")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class ExtractionError < Error
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class MediaUnavailableError < ExtractionError
|
|
20
|
+
getter url : String
|
|
21
|
+
|
|
22
|
+
def initialize(@url : String, reason : String)
|
|
23
|
+
super("media unavailable at #{@url}: #{reason}")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class InvalidExtractionError < ExtractionError
|
|
28
|
+
getter url : String
|
|
29
|
+
|
|
30
|
+
def initialize(@url : String, reason : String)
|
|
31
|
+
super("invalid extraction result for #{@url}: #{reason}")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class HTTPError < Error
|
|
36
|
+
getter url : URI
|
|
37
|
+
getter status : HTTP::Status
|
|
38
|
+
getter response_body : String
|
|
39
|
+
|
|
40
|
+
def initialize(@url : URI, @status : HTTP::Status, @response_body : String)
|
|
41
|
+
super("GET #{@url} returned #{@status.code} #{@status.description}")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class ResponseTooLargeError < Error
|
|
46
|
+
getter url : URI
|
|
47
|
+
getter limit : Int64
|
|
48
|
+
|
|
49
|
+
def initialize(@url : URI, @limit : Int64)
|
|
50
|
+
super("response from #{@url} exceeded #{@limit} bytes")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class TooManyRedirectsError < Error
|
|
55
|
+
getter url : URI
|
|
56
|
+
getter limit : Int32
|
|
57
|
+
|
|
58
|
+
def initialize(@url : URI, @limit : Int32)
|
|
59
|
+
super("request to #{@url} exceeded #{@limit} redirects")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class IncompleteResponseError < Error
|
|
64
|
+
getter url : URI
|
|
65
|
+
getter expected_bytes : Int64
|
|
66
|
+
getter actual_bytes : Int64
|
|
67
|
+
|
|
68
|
+
def initialize(@url : URI, @expected_bytes : Int64, @actual_bytes : Int64)
|
|
69
|
+
super("response from #{@url} ended after #{@actual_bytes} of #{@expected_bytes} bytes")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class NetworkError < Error
|
|
74
|
+
getter url : URI
|
|
75
|
+
|
|
76
|
+
def initialize(@url : URI, cause : Exception)
|
|
77
|
+
super("request to #{@url} failed: #{cause.message}", cause)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class InvalidUrlError < Error
|
|
82
|
+
getter url : String
|
|
83
|
+
|
|
84
|
+
def initialize(@url : String, reason : String)
|
|
85
|
+
super("invalid URL #{@url}: #{reason}")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class InvalidOptionsError < Error
|
|
90
|
+
def initialize(reason : String)
|
|
91
|
+
super("invalid request options: #{reason}")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
class MediaBackendUnavailableError < Error
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
class MediaMuxError < Error
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
class ManifestError < Error
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
class UnsupportedManifestError < ManifestError
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class CancelledError < Error
|
|
108
|
+
def initialize
|
|
109
|
+
super("operation cancelled")
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
class DownloadError < Error
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
class DownloadWriteError < DownloadError
|
|
117
|
+
getter path : String
|
|
118
|
+
|
|
119
|
+
def initialize(@path : String, cause : Exception)
|
|
120
|
+
super("failed to write #{@path}: #{cause.message}", cause)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
class FileExistsError < DownloadError
|
|
125
|
+
getter path : String
|
|
126
|
+
|
|
127
|
+
def initialize(@path : String)
|
|
128
|
+
super("file already exists: #{@path}")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
class IncompleteDownloadError < DownloadError
|
|
133
|
+
getter path : String
|
|
134
|
+
getter expected_bytes : Int64
|
|
135
|
+
getter actual_bytes : Int64
|
|
136
|
+
|
|
137
|
+
def initialize(@path : String, @expected_bytes : Int64, @actual_bytes : Int64)
|
|
138
|
+
super("incomplete download for #{@path}: expected #{@expected_bytes} bytes, got #{@actual_bytes}")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
class ResumeError < DownloadError
|
|
143
|
+
getter path : String
|
|
144
|
+
|
|
145
|
+
def initialize(@path : String, reason : String)
|
|
146
|
+
super("cannot resume #{@path}: #{reason}")
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
class InvalidOutputPathError < DownloadError
|
|
151
|
+
def initialize(path : String, directory : String)
|
|
152
|
+
super("refusing to write #{path} outside #{directory}")
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
require "./errors"
|
|
5
|
+
require "./format"
|
|
6
|
+
require "./http_client"
|
|
7
|
+
require "./media"
|
|
8
|
+
require "./playlist"
|
|
9
|
+
|
|
10
|
+
module Rill
|
|
11
|
+
abstract class Extractor
|
|
12
|
+
abstract def name : String
|
|
13
|
+
|
|
14
|
+
abstract def matches?(url : URI) : Bool
|
|
15
|
+
|
|
16
|
+
abstract def extract(url : URI, client : HTTPClient) : Media | Playlist
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
require "./errors"
|
|
4
|
+
require "./extractor"
|
|
5
|
+
require "./extractors/bandcamp"
|
|
6
|
+
require "./extractors/dash"
|
|
7
|
+
require "./extractors/direct_media"
|
|
8
|
+
require "./extractors/generic"
|
|
9
|
+
require "./extractors/hls"
|
|
10
|
+
require "./extractors/internet_archive"
|
|
11
|
+
require "./extractors/m3u"
|
|
12
|
+
require "./extractors/media_ccc_de"
|
|
13
|
+
require "./extractors/mixcloud"
|
|
14
|
+
require "./extractors/peer_tube"
|
|
15
|
+
require "./extractors/rumble"
|
|
16
|
+
require "./extractors/soundcloud"
|
|
17
|
+
require "./extractors/spotify"
|
|
18
|
+
require "./extractors/twitch_clip"
|
|
19
|
+
require "./extractors/youtube"
|
|
20
|
+
require "./http_client"
|
|
21
|
+
require "./playlist"
|
|
22
|
+
require "./request_options"
|
|
23
|
+
|
|
24
|
+
module Rill
|
|
25
|
+
class ExtractorRegistry
|
|
26
|
+
def self.default : self
|
|
27
|
+
registry = new
|
|
28
|
+
registry.register(Extractors::InternetArchive)
|
|
29
|
+
registry.register(Extractors::MediaCccDe)
|
|
30
|
+
registry.register(Extractors::PeerTube)
|
|
31
|
+
registry.register(Extractors::YouTube)
|
|
32
|
+
registry.register(Extractors::Spotify)
|
|
33
|
+
registry.register(Extractors::Bandcamp)
|
|
34
|
+
registry.register(Extractors::Rumble)
|
|
35
|
+
registry.register(Extractors::SoundCloud)
|
|
36
|
+
registry.register(Extractors::Mixcloud)
|
|
37
|
+
registry.register(Extractors::TwitchClip)
|
|
38
|
+
registry.register(Extractors::Hls)
|
|
39
|
+
registry.register(Extractors::Dash)
|
|
40
|
+
registry.register(Extractors::M3u)
|
|
41
|
+
registry.register(Extractors::DirectMedia)
|
|
42
|
+
registry.register(Extractors::Generic)
|
|
43
|
+
registry
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize
|
|
47
|
+
@extractors = [] of Extractor.class
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def register(extractor : Extractor.class) : Nil
|
|
51
|
+
@extractors << extractor
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def extractors : Array(Extractor.class)
|
|
55
|
+
@extractors.dup
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def extractor_for(url : String | URI) : Extractor.class | Nil
|
|
59
|
+
uri = url.is_a?(URI) ? url : URI.parse(url)
|
|
60
|
+
return nil unless uri.scheme == "http" || uri.scheme == "https"
|
|
61
|
+
|
|
62
|
+
@extractors.find { |extractor| extractor.new.matches?(uri) }
|
|
63
|
+
rescue URI::Error
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def extract(url : String | URI, options : RequestOptions = RequestOptions.new) : Media | Playlist
|
|
68
|
+
extract(url, HTTPClient.new(options))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def extract(url : String | URI, client : HTTPClient) : Media | Playlist
|
|
72
|
+
uri = parse_url(url)
|
|
73
|
+
extractor = extractor_for(uri)
|
|
74
|
+
unless extractor
|
|
75
|
+
raise UnsupportedUrlError.new(uri.to_s)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
result = extractor.new.extract(uri, client)
|
|
79
|
+
case result
|
|
80
|
+
when Media
|
|
81
|
+
result.validate!
|
|
82
|
+
when Playlist
|
|
83
|
+
result.validate!
|
|
84
|
+
end
|
|
85
|
+
result
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private def parse_url(url : String | URI) : URI
|
|
89
|
+
uri = url.is_a?(URI) ? url : URI.parse(url)
|
|
90
|
+
unless uri.scheme == "http" || uri.scheme == "https"
|
|
91
|
+
raise UnsupportedUrlError.new(uri.to_s)
|
|
92
|
+
end
|
|
93
|
+
uri
|
|
94
|
+
rescue URI::Error
|
|
95
|
+
raise UnsupportedUrlError.new(url.to_s)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
require "html"
|
|
2
|
+
require "json"
|
|
3
|
+
require "time"
|
|
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
|
+
require "../playlist"
|
|
13
|
+
|
|
14
|
+
module Rill::Extractors
|
|
15
|
+
class Bandcamp < Extractor
|
|
16
|
+
HOST = /\A[a-z0-9-]+\.bandcamp\.com\z/i
|
|
17
|
+
PATH = %r{\A/(track|album)/([^/]+)/?\z}
|
|
18
|
+
ISO_DATE = /\A\d{4}-\d{2}-\d{2}/
|
|
19
|
+
DATE = "%d %b %Y %H:%M:%S GMT"
|
|
20
|
+
TRALBUM = /data-tralbum\s*=\s*(?:"([^"]*)"|'([^']*)')/m
|
|
21
|
+
OG_IMAGE = /\bproperty\s*=\s*(?:"og:image"|'og:image')/i
|
|
22
|
+
CONTENT = /\bcontent\s*=\s*(?:"([^"]*)"|'([^']*)')/i
|
|
23
|
+
|
|
24
|
+
def name : String
|
|
25
|
+
"bandcamp"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def matches?(url : URI) : Bool
|
|
29
|
+
return false unless http_url?(url)
|
|
30
|
+
return false unless artist_host?(url.host)
|
|
31
|
+
|
|
32
|
+
!PATH.match(url.path).nil?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def extract(url : URI, client : HTTPClient) : Media | Playlist
|
|
36
|
+
match = PATH.match(url.path)
|
|
37
|
+
unless match
|
|
38
|
+
raise UnsupportedUrlError.new(url.to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
response = fetch_page(client, url)
|
|
42
|
+
tralbum = tralbum_data(response.body, url)
|
|
43
|
+
|
|
44
|
+
if match[1] == "track"
|
|
45
|
+
extract_track(url, tralbum, thumbnail_url(response.body, response.url))
|
|
46
|
+
else
|
|
47
|
+
extract_album(url, tralbum)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private def http_url?(url : URI) : Bool
|
|
52
|
+
url.scheme == "http" || url.scheme == "https"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private def artist_host?(host : String?) : Bool
|
|
56
|
+
return false unless host
|
|
57
|
+
|
|
58
|
+
name = host.downcase
|
|
59
|
+
return false if name == "www.bandcamp.com"
|
|
60
|
+
|
|
61
|
+
!HOST.match(name).nil?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private def fetch_page(client : HTTPClient, url : URI) : HTTPClient::Response
|
|
65
|
+
client.get(url)
|
|
66
|
+
rescue ex : HTTPError
|
|
67
|
+
if ex.status.code == 404
|
|
68
|
+
raise MediaUnavailableError.new(url.to_s, "page not found")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
raise ex
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private def tralbum_data(body : String, url : URI) : Hash(String, JSON::Any)
|
|
75
|
+
attribute = tralbum_attribute(body)
|
|
76
|
+
unless attribute
|
|
77
|
+
raise MediaUnavailableError.new(url.to_s, "page has no data-tralbum attribute")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
payload = JSON.parse(HTML.unescape(attribute)).as_h?
|
|
81
|
+
unless payload
|
|
82
|
+
raise MediaUnavailableError.new(url.to_s, "data-tralbum is not a JSON object")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
payload
|
|
86
|
+
rescue JSON::ParseException
|
|
87
|
+
raise MediaUnavailableError.new(url.to_s, "data-tralbum is not valid JSON")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private def tralbum_attribute(body : String) : String?
|
|
91
|
+
match = TRALBUM.match(body)
|
|
92
|
+
return nil unless match
|
|
93
|
+
|
|
94
|
+
match[1]? || match[2]?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private def extract_track(
|
|
98
|
+
url : URI,
|
|
99
|
+
tralbum : Hash(String, JSON::Any),
|
|
100
|
+
thumbnail : URI?,
|
|
101
|
+
) : Media
|
|
102
|
+
if tralbum["is_private_stream"]?.try(&.as_bool?)
|
|
103
|
+
raise MediaUnavailableError.new(url.to_s, "track stream is private")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
current = tralbum["current"]?.try(&.as_h?) || {} of String => JSON::Any
|
|
107
|
+
track = first_track(tralbum, url)
|
|
108
|
+
|
|
109
|
+
if track["private"]?.try(&.as_bool?)
|
|
110
|
+
raise MediaUnavailableError.new(url.to_s, "track is private")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
title = JsonValue.string(current["title"]?) || JsonValue.string(track["title"]?)
|
|
114
|
+
unless title
|
|
115
|
+
raise MediaUnavailableError.new(url.to_s, "track title is missing")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
duration = JsonValue.seconds(track["duration"]?)
|
|
119
|
+
format = audio_format(track, url, duration)
|
|
120
|
+
|
|
121
|
+
Media.new(
|
|
122
|
+
id: track_id(track) || JsonValue.string(tralbum["id"]?),
|
|
123
|
+
title: title,
|
|
124
|
+
description: JsonValue.string(current["about"]?),
|
|
125
|
+
webpage_url: url,
|
|
126
|
+
uploader: JsonValue.string(tralbum["artist"]?) || JsonValue.string(current["artist"]?),
|
|
127
|
+
duration_seconds: duration,
|
|
128
|
+
thumbnail_url: thumbnail,
|
|
129
|
+
upload_date: upload_date(current),
|
|
130
|
+
extractor: name,
|
|
131
|
+
formats: [format],
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private def first_track(
|
|
136
|
+
tralbum : Hash(String, JSON::Any),
|
|
137
|
+
url : URI,
|
|
138
|
+
) : Hash(String, JSON::Any)
|
|
139
|
+
tracks = tralbum["trackinfo"]?.try(&.as_a?)
|
|
140
|
+
unless tracks
|
|
141
|
+
raise MediaUnavailableError.new(url.to_s, "page has no trackinfo")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
track = tracks.first?.try(&.as_h?)
|
|
145
|
+
unless track
|
|
146
|
+
raise MediaUnavailableError.new(url.to_s, "track data is missing")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
track
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private def audio_format(
|
|
153
|
+
track : Hash(String, JSON::Any),
|
|
154
|
+
url : URI,
|
|
155
|
+
duration : Float64?,
|
|
156
|
+
) : Format
|
|
157
|
+
file = track["file"]?.try(&.as_h?)
|
|
158
|
+
value = file.try { |hash| JsonValue.string(hash["mp3-128"]?) }
|
|
159
|
+
unless value
|
|
160
|
+
raise MediaUnavailableError.new(url.to_s, "stream is unavailable")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
stream_url = HttpUrl.absolute(protocol_url(value))
|
|
164
|
+
unless stream_url
|
|
165
|
+
raise MediaUnavailableError.new(url.to_s, "stream URL is invalid")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
Format.new(
|
|
169
|
+
format_id: "mp3-128",
|
|
170
|
+
url: stream_url,
|
|
171
|
+
kind: FormatKind::Audio,
|
|
172
|
+
container: "mp3",
|
|
173
|
+
bitrate: 128_000_i64,
|
|
174
|
+
duration_seconds: duration,
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private def protocol_url(value : String) : String
|
|
179
|
+
value.starts_with?("//") ? "https:#{value}" : value
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private def extract_album(
|
|
183
|
+
page_url : URI,
|
|
184
|
+
tralbum : Hash(String, JSON::Any),
|
|
185
|
+
) : Playlist
|
|
186
|
+
current = tralbum["current"]?.try(&.as_h?) || {} of String => JSON::Any
|
|
187
|
+
title = JsonValue.string(current["title"]?)
|
|
188
|
+
unless title
|
|
189
|
+
raise MediaUnavailableError.new(page_url.to_s, "album title is missing")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
entries = album_entries(tralbum["trackinfo"]?, page_url)
|
|
193
|
+
if entries.empty?
|
|
194
|
+
raise MediaUnavailableError.new(page_url.to_s, "album has no tracks")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
Playlist.new(
|
|
198
|
+
id: JsonValue.string(current["id"]?),
|
|
199
|
+
title: title,
|
|
200
|
+
webpage_url: page_url,
|
|
201
|
+
extractor: name,
|
|
202
|
+
entries: entries,
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private def album_entries(
|
|
207
|
+
value : JSON::Any?,
|
|
208
|
+
page_url : URI,
|
|
209
|
+
) : Array(PlaylistEntry)
|
|
210
|
+
tracks = value.try(&.as_a?)
|
|
211
|
+
return [] of PlaylistEntry unless tracks
|
|
212
|
+
|
|
213
|
+
entries = [] of PlaylistEntry
|
|
214
|
+
tracks.each do |item|
|
|
215
|
+
track = item.as_h?
|
|
216
|
+
next unless track
|
|
217
|
+
|
|
218
|
+
title_link = JsonValue.string(track["title_link"]?)
|
|
219
|
+
next unless title_link
|
|
220
|
+
|
|
221
|
+
entry_url = HttpUrl.resolve(page_url, title_link)
|
|
222
|
+
next unless entry_url
|
|
223
|
+
|
|
224
|
+
entries << PlaylistEntry.new(
|
|
225
|
+
url: entry_url,
|
|
226
|
+
title: JsonValue.string(track["title"]?),
|
|
227
|
+
id: track_id(track),
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
entries
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private def track_id(track : Hash(String, JSON::Any)) : String?
|
|
235
|
+
JsonValue.string(track["id"]?) || JsonValue.string(track["track_id"]?)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private def upload_date(current : Hash(String, JSON::Any)) : String?
|
|
239
|
+
value = JsonValue.string(current["release_date"]?) || JsonValue.string(current["publish_date"]?)
|
|
240
|
+
return nil unless value
|
|
241
|
+
return value[0, 10] if ISO_DATE.match(value)
|
|
242
|
+
|
|
243
|
+
Time.parse(value, DATE, Time::Location::UTC).to_s("%Y-%m-%d")
|
|
244
|
+
rescue Time::Format::Error
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private def thumbnail_url(body : String, base_url : URI) : URI?
|
|
249
|
+
body.scan(/<meta\b[^>]*>/i) do |match|
|
|
250
|
+
meta = match[0]
|
|
251
|
+
next unless OG_IMAGE.match(meta)
|
|
252
|
+
|
|
253
|
+
content = CONTENT.match(meta)
|
|
254
|
+
next unless content
|
|
255
|
+
|
|
256
|
+
value = content[1]? || content[2]?
|
|
257
|
+
next unless value
|
|
258
|
+
|
|
259
|
+
return HttpUrl.resolve(base_url, HTML.unescape(value))
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
require "../dash/parser"
|
|
4
|
+
require "../errors"
|
|
5
|
+
require "../extractor"
|
|
6
|
+
require "../format"
|
|
7
|
+
require "../media"
|
|
8
|
+
|
|
9
|
+
module Rill::Extractors
|
|
10
|
+
class Dash < Extractor
|
|
11
|
+
def name : String
|
|
12
|
+
"dash"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def matches?(url : URI) : Bool
|
|
16
|
+
return false unless url.scheme == "http" || url.scheme == "https"
|
|
17
|
+
|
|
18
|
+
File.extname(url.path).downcase == ".mpd"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def extract(url : URI, client : HTTPClient) : Media
|
|
22
|
+
response = client.get(url)
|
|
23
|
+
manifest = Rill::Dash::Parser.parse(response.body, response.url)
|
|
24
|
+
|
|
25
|
+
formats = [] of Format
|
|
26
|
+
unsupported = false
|
|
27
|
+
|
|
28
|
+
manifest.representations.each do |representation|
|
|
29
|
+
unless representation.supported?
|
|
30
|
+
unsupported = true
|
|
31
|
+
next
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
mime = representation.mime
|
|
35
|
+
next unless mime
|
|
36
|
+
|
|
37
|
+
audio = mime.audio?
|
|
38
|
+
formats << Format.new(
|
|
39
|
+
format_id: representation.reference,
|
|
40
|
+
url: response.url,
|
|
41
|
+
kind: audio ? FormatKind::Audio : FormatKind::Video,
|
|
42
|
+
protocol: FormatProtocol::Dash,
|
|
43
|
+
container: mime.container,
|
|
44
|
+
bitrate: representation.bandwidth,
|
|
45
|
+
video_codec: audio ? nil : (representation.codecs || mime.codecs),
|
|
46
|
+
audio_codec: audio ? (representation.codecs || mime.audio_codec) : nil,
|
|
47
|
+
width: representation.width,
|
|
48
|
+
height: representation.height,
|
|
49
|
+
fps: representation.fps,
|
|
50
|
+
duration_seconds: representation.duration_seconds,
|
|
51
|
+
language: representation.language,
|
|
52
|
+
note: representation.codecs || "DASH",
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if formats.empty?
|
|
57
|
+
reason = unsupported ? "DASH representations use unsupported segment information" : "DASH manifest has no representations"
|
|
58
|
+
raise MediaUnavailableError.new(url.to_s, reason)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
Media.new(
|
|
62
|
+
title: title_for(url),
|
|
63
|
+
webpage_url: url,
|
|
64
|
+
duration_seconds: manifest.duration_seconds,
|
|
65
|
+
extractor: name,
|
|
66
|
+
formats: formats,
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private def title_for(url : URI) : String
|
|
71
|
+
basename = File.basename(url.path)
|
|
72
|
+
stem = File.basename(basename, File.extname(basename))
|
|
73
|
+
decoded = URI.decode(stem)
|
|
74
|
+
decoded.empty? ? "manifest" : decoded
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
require "../extractor"
|
|
4
|
+
require "../format"
|
|
5
|
+
require "../media"
|
|
6
|
+
|
|
7
|
+
module Rill::Extractors
|
|
8
|
+
class DirectMedia < Extractor
|
|
9
|
+
VIDEO_EXTENSIONS = %w(
|
|
10
|
+
3gp avi flv m2ts mkv mov mp4 m4v mpg mpeg ogv ts webm wmv
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
AUDIO_EXTENSIONS = %w(
|
|
14
|
+
aac aif aiff ape flac m4a mp3 oga ogg opus shn wav wma
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def name : String
|
|
18
|
+
"direct"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def matches?(url : URI) : Bool
|
|
22
|
+
extension = extension_for(url)
|
|
23
|
+
!extension.nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def extract(url : URI, client : HTTPClient) : Media
|
|
27
|
+
extension = extension_for(url)
|
|
28
|
+
unless extension
|
|
29
|
+
raise UnsupportedUrlError.new(url.to_s)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
format = Format.new(
|
|
33
|
+
format_id: "direct",
|
|
34
|
+
url: url,
|
|
35
|
+
kind: kind_for(extension),
|
|
36
|
+
container: extension,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
Media.new(
|
|
40
|
+
title: title_for(url),
|
|
41
|
+
webpage_url: url,
|
|
42
|
+
extractor: name,
|
|
43
|
+
formats: [format],
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private def extension_for(url : URI) : String?
|
|
48
|
+
return nil unless url.scheme == "http" || url.scheme == "https"
|
|
49
|
+
|
|
50
|
+
extension = File.extname(url.path).lchop('.').downcase
|
|
51
|
+
return nil if extension.empty?
|
|
52
|
+
|
|
53
|
+
if VIDEO_EXTENSIONS.includes?(extension) || AUDIO_EXTENSIONS.includes?(extension)
|
|
54
|
+
extension
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private def kind_for(extension : String) : FormatKind
|
|
59
|
+
if AUDIO_EXTENSIONS.includes?(extension)
|
|
60
|
+
FormatKind::Audio
|
|
61
|
+
else
|
|
62
|
+
FormatKind::Combined
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private def title_for(url : URI) : String
|
|
67
|
+
basename = File.basename(url.path)
|
|
68
|
+
stem = File.basename(basename, File.extname(basename))
|
|
69
|
+
decoded = URI.decode(stem)
|
|
70
|
+
decoded.empty? ? "media" : decoded
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|