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,151 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "set"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
require "./errors"
|
|
6
|
+
require "./format"
|
|
7
|
+
|
|
8
|
+
module Rill
|
|
9
|
+
class Media
|
|
10
|
+
getter id : String?
|
|
11
|
+
getter title : String
|
|
12
|
+
getter description : String?
|
|
13
|
+
getter webpage_url : URI
|
|
14
|
+
getter uploader : String?
|
|
15
|
+
getter duration_seconds : Float64?
|
|
16
|
+
getter thumbnail_url : URI?
|
|
17
|
+
getter upload_date : String?
|
|
18
|
+
getter extractor : String
|
|
19
|
+
getter formats : Array(Format)
|
|
20
|
+
getter warnings : Array(String)
|
|
21
|
+
|
|
22
|
+
def initialize(
|
|
23
|
+
@title : String,
|
|
24
|
+
@webpage_url : URI,
|
|
25
|
+
@extractor : String,
|
|
26
|
+
@formats : Array(Format),
|
|
27
|
+
@id : String? = nil,
|
|
28
|
+
@description : String? = nil,
|
|
29
|
+
@uploader : String? = nil,
|
|
30
|
+
@duration_seconds : Float64? = nil,
|
|
31
|
+
@thumbnail_url : URI? = nil,
|
|
32
|
+
@upload_date : String? = nil,
|
|
33
|
+
@warnings : Array(String) = [] of String,
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def validate! : Nil
|
|
38
|
+
if title.empty?
|
|
39
|
+
raise InvalidExtractionError.new(webpage_url.to_s, "title is empty")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if formats.empty?
|
|
43
|
+
raise InvalidExtractionError.new(webpage_url.to_s, "no formats")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
validate_duration!(duration_seconds, "media duration")
|
|
47
|
+
|
|
48
|
+
format_ids = Set(String).new
|
|
49
|
+
formats.each do |format|
|
|
50
|
+
validate_format!(format, format_ids)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def to_json(json : JSON::Builder) : Nil
|
|
55
|
+
json.object do
|
|
56
|
+
json.field "id", id
|
|
57
|
+
json.field "title", title
|
|
58
|
+
json.field "description", description
|
|
59
|
+
json.field "webpage_url", webpage_url.to_s
|
|
60
|
+
json.field "uploader", uploader
|
|
61
|
+
json.field "duration_seconds", duration_seconds
|
|
62
|
+
json.field "thumbnail_url", thumbnail_url.try(&.to_s)
|
|
63
|
+
json.field "upload_date", upload_date
|
|
64
|
+
json.field "extractor", extractor
|
|
65
|
+
json.field "formats" do
|
|
66
|
+
json.array do
|
|
67
|
+
formats.each(&.to_json(json))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
json.field "warnings" do
|
|
71
|
+
json.array do
|
|
72
|
+
warnings.each { |warning| json.string(warning) }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def to_pretty_json : String
|
|
79
|
+
JSON.build(indent: 2) do |json|
|
|
80
|
+
to_json(json)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private def validate_format!(format : Format, format_ids : Set(String)) : Nil
|
|
85
|
+
if format.url.host.nil?
|
|
86
|
+
raise InvalidExtractionError.new(webpage_url.to_s, "format #{format.format_id} has no host")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
scheme = format.url.scheme
|
|
90
|
+
if scheme != "http" && scheme != "https"
|
|
91
|
+
raise InvalidExtractionError.new(webpage_url.to_s, "format #{format.format_id} has a non-HTTP URL")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
unless format_ids.add?(format.format_id)
|
|
95
|
+
raise InvalidExtractionError.new(webpage_url.to_s, "duplicate format id #{format.format_id}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
validate_duration!(format.duration_seconds, "format #{format.format_id} duration")
|
|
99
|
+
validate_positive!(format.width, "format #{format.format_id} width")
|
|
100
|
+
validate_positive!(format.height, "format #{format.format_id} height")
|
|
101
|
+
validate_non_negative!(format.filesize, "format #{format.format_id} filesize")
|
|
102
|
+
|
|
103
|
+
if chunk_size = format.range_chunk_size
|
|
104
|
+
if chunk_size <= 0
|
|
105
|
+
raise InvalidExtractionError.new(webpage_url.to_s, "format #{format.format_id} chunk size is invalid")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
validate_non_negative!(format.video_bitrate, "format #{format.format_id} video bitrate")
|
|
109
|
+
validate_non_negative!(format.audio_bitrate, "format #{format.format_id} audio bitrate")
|
|
110
|
+
validate_non_negative!(format.bitrate, "format #{format.format_id} bitrate")
|
|
111
|
+
|
|
112
|
+
if fps = format.fps
|
|
113
|
+
unless fps.finite? && fps > 0
|
|
114
|
+
raise InvalidExtractionError.new(webpage_url.to_s, "format #{format.format_id} has an invalid fps")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private def validate_duration!(value : Float64?, label : String) : Nil
|
|
120
|
+
return unless value
|
|
121
|
+
|
|
122
|
+
unless value.finite? && value >= 0
|
|
123
|
+
raise InvalidExtractionError.new(webpage_url.to_s, "#{label} is invalid")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private def validate_positive!(value : Int32?, label : String) : Nil
|
|
128
|
+
return unless value
|
|
129
|
+
|
|
130
|
+
if value <= 0
|
|
131
|
+
raise InvalidExtractionError.new(webpage_url.to_s, "#{label} is invalid")
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private def validate_non_negative!(value : Int32?, label : String) : Nil
|
|
136
|
+
return unless value
|
|
137
|
+
|
|
138
|
+
if value < 0
|
|
139
|
+
raise InvalidExtractionError.new(webpage_url.to_s, "#{label} is invalid")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private def validate_non_negative!(value : Int64?, label : String) : Nil
|
|
144
|
+
return unless value
|
|
145
|
+
|
|
146
|
+
if value < 0
|
|
147
|
+
raise InvalidExtractionError.new(webpage_url.to_s, "#{label} is invalid")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
require "path"
|
|
2
|
+
require "process"
|
|
3
|
+
|
|
4
|
+
require "./cancellation"
|
|
5
|
+
require "./errors"
|
|
6
|
+
|
|
7
|
+
module Rill
|
|
8
|
+
class MediaMuxer
|
|
9
|
+
enum Backend
|
|
10
|
+
Auto
|
|
11
|
+
Helper
|
|
12
|
+
System
|
|
13
|
+
Off
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
HELPER_NAME = {% if flag?(:win32) %} "rill-media.exe" {% else %} "rill-media" {% end %}
|
|
17
|
+
|
|
18
|
+
@backend : Backend
|
|
19
|
+
@helper_path : String?
|
|
20
|
+
@ffmpeg_path : String
|
|
21
|
+
|
|
22
|
+
def initialize(
|
|
23
|
+
@backend : Backend = self.class.backend_from_environment,
|
|
24
|
+
@helper_path : String? = nil,
|
|
25
|
+
@ffmpeg_path : String = ENV["RILL_FFMPEG"]? || "ffmpeg",
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.backend_from_environment : Backend
|
|
30
|
+
case ENV["RILL_MEDIA_BACKEND"]?.try(&.downcase)
|
|
31
|
+
when "helper"
|
|
32
|
+
Backend::Helper
|
|
33
|
+
when "system"
|
|
34
|
+
Backend::System
|
|
35
|
+
when "off", "none", "disabled"
|
|
36
|
+
Backend::Off
|
|
37
|
+
else
|
|
38
|
+
Backend::Auto
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.parse_backend(value : String) : Backend?
|
|
43
|
+
case value.downcase
|
|
44
|
+
when "auto"
|
|
45
|
+
Backend::Auto
|
|
46
|
+
when "helper"
|
|
47
|
+
Backend::Helper
|
|
48
|
+
when "system"
|
|
49
|
+
Backend::System
|
|
50
|
+
when "off", "none", "disabled"
|
|
51
|
+
Backend::Off
|
|
52
|
+
else
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def merge(
|
|
58
|
+
video : String | Path,
|
|
59
|
+
audio : String | Path,
|
|
60
|
+
output : String | Path,
|
|
61
|
+
overwrite : Bool = false,
|
|
62
|
+
cancellation : Cancellation? = nil,
|
|
63
|
+
) : Path
|
|
64
|
+
video_path = Path.new(video.to_s)
|
|
65
|
+
audio_path = Path.new(audio.to_s)
|
|
66
|
+
output_path = Path.new(output.to_s)
|
|
67
|
+
|
|
68
|
+
validate_paths!(video_path, audio_path, output_path, overwrite)
|
|
69
|
+
backend = resolve_backend
|
|
70
|
+
temporary_path = temporary_output_path(output_path)
|
|
71
|
+
File.delete(temporary_path) if File.exists?(temporary_path)
|
|
72
|
+
|
|
73
|
+
begin
|
|
74
|
+
case backend
|
|
75
|
+
when :helper
|
|
76
|
+
run_helper(video_path, audio_path, temporary_path, overwrite, cancellation)
|
|
77
|
+
when :system
|
|
78
|
+
run_system(video_path, audio_path, temporary_path, overwrite, cancellation)
|
|
79
|
+
else
|
|
80
|
+
raise MediaBackendUnavailableError.new("no media backend is available")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
publish(temporary_path, output_path, overwrite)
|
|
84
|
+
ensure
|
|
85
|
+
File.delete(temporary_path) if File.exists?(temporary_path)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
output_path
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private def temporary_output_path(output : Path) : Path
|
|
92
|
+
extension = output.extension
|
|
93
|
+
stem = output.basename.chomp(extension)
|
|
94
|
+
output.parent / "#{stem}.part#{extension}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private def publish(temporary : Path, output : Path, overwrite : Bool) : Nil
|
|
98
|
+
if !overwrite && File.exists?(output)
|
|
99
|
+
raise FileExistsError.new(output.to_s)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
begin
|
|
103
|
+
File.rename(temporary, output)
|
|
104
|
+
rescue ex : File::Error
|
|
105
|
+
unless overwrite && File.exists?(output)
|
|
106
|
+
raise ex
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
File.delete(output)
|
|
110
|
+
File.rename(temporary, output)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private def resolve_backend : Symbol
|
|
115
|
+
case @backend
|
|
116
|
+
when .off?
|
|
117
|
+
raise MediaBackendUnavailableError.new("media processing is disabled")
|
|
118
|
+
when .helper?
|
|
119
|
+
unless find_helper
|
|
120
|
+
raise MediaBackendUnavailableError.new("media helper not found: #{HELPER_NAME}")
|
|
121
|
+
end
|
|
122
|
+
:helper
|
|
123
|
+
when .system?
|
|
124
|
+
unless find_ffmpeg
|
|
125
|
+
raise MediaBackendUnavailableError.new("ffmpeg not found: #{@ffmpeg_path}")
|
|
126
|
+
end
|
|
127
|
+
:system
|
|
128
|
+
when .auto?
|
|
129
|
+
if find_helper
|
|
130
|
+
:helper
|
|
131
|
+
elsif find_ffmpeg
|
|
132
|
+
:system
|
|
133
|
+
else
|
|
134
|
+
raise MediaBackendUnavailableError.new("no media helper or ffmpeg was found")
|
|
135
|
+
end
|
|
136
|
+
else
|
|
137
|
+
raise MediaBackendUnavailableError.new("unknown media backend")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private def validate_paths!(
|
|
142
|
+
video : Path,
|
|
143
|
+
audio : Path,
|
|
144
|
+
output : Path,
|
|
145
|
+
overwrite : Bool,
|
|
146
|
+
) : Nil
|
|
147
|
+
unless File.file?(video)
|
|
148
|
+
raise MediaMuxError.new("video input not found: #{video}")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
unless File.file?(audio)
|
|
152
|
+
raise MediaMuxError.new("audio input not found: #{audio}")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
if File.directory?(output)
|
|
156
|
+
raise MediaMuxError.new("output is a directory: #{output}")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
if File.exists?(output) && !overwrite
|
|
160
|
+
raise FileExistsError.new(output.to_s)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
expanded_output = output.expand
|
|
164
|
+
if video.expand == expanded_output || audio.expand == expanded_output
|
|
165
|
+
raise MediaMuxError.new("output must differ from the inputs")
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private def run_helper(
|
|
170
|
+
video : Path,
|
|
171
|
+
audio : Path,
|
|
172
|
+
output : Path,
|
|
173
|
+
overwrite : Bool,
|
|
174
|
+
cancellation : Cancellation?,
|
|
175
|
+
) : Nil
|
|
176
|
+
helper = find_helper.not_nil!
|
|
177
|
+
args = [
|
|
178
|
+
"merge",
|
|
179
|
+
"--video", video.to_s,
|
|
180
|
+
"--audio", audio.to_s,
|
|
181
|
+
"--output", output.to_s,
|
|
182
|
+
]
|
|
183
|
+
args << "--overwrite" if overwrite
|
|
184
|
+
|
|
185
|
+
run_process(helper, args, cancellation, "media helper")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private def run_system(
|
|
189
|
+
video : Path,
|
|
190
|
+
audio : Path,
|
|
191
|
+
output : Path,
|
|
192
|
+
overwrite : Bool,
|
|
193
|
+
cancellation : Cancellation?,
|
|
194
|
+
) : Nil
|
|
195
|
+
ffmpeg = find_ffmpeg.not_nil!
|
|
196
|
+
args = [
|
|
197
|
+
"-hide_banner",
|
|
198
|
+
"-loglevel", "error",
|
|
199
|
+
"-nostdin",
|
|
200
|
+
overwrite ? "-y" : "-n",
|
|
201
|
+
"-i", video.to_s,
|
|
202
|
+
"-i", audio.to_s,
|
|
203
|
+
"-map", "0:v:0?",
|
|
204
|
+
"-map", "1:a:0?",
|
|
205
|
+
"-c", "copy",
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
if {".mp4", ".m4v", ".mov"}.includes?(output.extension.downcase)
|
|
209
|
+
args.concat(["-movflags", "+faststart"])
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
args << output.to_s
|
|
213
|
+
run_process(ffmpeg, args, cancellation, "ffmpeg")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private def run_process(
|
|
217
|
+
command : String,
|
|
218
|
+
args : Array(String),
|
|
219
|
+
cancellation : Cancellation?,
|
|
220
|
+
label : String,
|
|
221
|
+
) : Nil
|
|
222
|
+
error_io = IO::Memory.new
|
|
223
|
+
process = begin
|
|
224
|
+
Process.new(
|
|
225
|
+
command,
|
|
226
|
+
args,
|
|
227
|
+
input: Process::Redirect::Close,
|
|
228
|
+
output: Process::Redirect::Close,
|
|
229
|
+
error: error_io,
|
|
230
|
+
)
|
|
231
|
+
rescue File::NotFoundError
|
|
232
|
+
raise MediaBackendUnavailableError.new("#{label} not found: #{command}")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
status : Process::Status? = nil
|
|
236
|
+
|
|
237
|
+
begin
|
|
238
|
+
loop do
|
|
239
|
+
if cancellation.try(&.cancelled?)
|
|
240
|
+
process.terminate unless process.terminated?
|
|
241
|
+
status = process.wait
|
|
242
|
+
raise CancelledError.new
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
break if process.terminated?
|
|
246
|
+
sleep 50.milliseconds
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
status = process.wait
|
|
250
|
+
ensure
|
|
251
|
+
if status.nil?
|
|
252
|
+
process.terminate unless process.terminated?
|
|
253
|
+
process.wait
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
unless status.not_nil!.success?
|
|
258
|
+
message = error_io.to_s.strip
|
|
259
|
+
message = "#{label} exited with status #{status.not_nil!.exit_code}" if message.empty?
|
|
260
|
+
raise MediaMuxError.new(message)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
private def find_helper : String?
|
|
265
|
+
candidates = [] of String
|
|
266
|
+
|
|
267
|
+
if value = ENV["RILL_MEDIA_HELPER"]?
|
|
268
|
+
candidates << value
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
if value = @helper_path
|
|
272
|
+
candidates << value
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
if executable = Process.executable_path
|
|
276
|
+
executable_directory = File.dirname(executable)
|
|
277
|
+
candidates << File.join(executable_directory, HELPER_NAME)
|
|
278
|
+
candidates << File.join(executable_directory, "..", "build", HELPER_NAME)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
candidates << File.join("build", HELPER_NAME)
|
|
282
|
+
candidates << File.join("build", "native", HELPER_NAME)
|
|
283
|
+
|
|
284
|
+
candidates.each do |candidate|
|
|
285
|
+
if candidate.includes?('/') || candidate.includes?('\\')
|
|
286
|
+
return candidate if executable_file?(candidate)
|
|
287
|
+
elsif found = find_in_path(candidate)
|
|
288
|
+
return found
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
nil
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
private def find_ffmpeg : String?
|
|
296
|
+
if @ffmpeg_path.includes?('/') || @ffmpeg_path.includes?('\\')
|
|
297
|
+
return @ffmpeg_path if executable_file?(@ffmpeg_path)
|
|
298
|
+
return nil
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
find_in_path(@ffmpeg_path)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
private def executable_file?(path : String) : Bool
|
|
305
|
+
{% if flag?(:win32) %}
|
|
306
|
+
File.file?(path)
|
|
307
|
+
{% else %}
|
|
308
|
+
info = File.info?(path)
|
|
309
|
+
return false unless info && info.file?
|
|
310
|
+
|
|
311
|
+
permissions = info.permissions
|
|
312
|
+
permissions.owner_execute? || permissions.group_execute? || permissions.other_execute?
|
|
313
|
+
{% end %}
|
|
314
|
+
rescue File::Error
|
|
315
|
+
false
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
private def find_in_path(name : String) : String?
|
|
319
|
+
path = ENV["PATH"]?
|
|
320
|
+
return nil unless path
|
|
321
|
+
|
|
322
|
+
separator = {% if flag?(:win32) %} ';' {% else %} ':' {% end %}
|
|
323
|
+
path.split(separator).each do |directory|
|
|
324
|
+
next if directory.empty?
|
|
325
|
+
|
|
326
|
+
candidate = File.join(directory, name)
|
|
327
|
+
return candidate if executable_file?(candidate)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
nil
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Rill
|
|
2
|
+
struct MimeType
|
|
3
|
+
getter type : String
|
|
4
|
+
getter subtype : String
|
|
5
|
+
getter codecs : String?
|
|
6
|
+
|
|
7
|
+
def self.parse(value : String) : self
|
|
8
|
+
parts = value.split(';').map(&.strip)
|
|
9
|
+
media_type = parts.first?.try(&.downcase) || ""
|
|
10
|
+
type, _, subtype = media_type.partition('/')
|
|
11
|
+
|
|
12
|
+
codecs = parts.skip(1).compact_map do |part|
|
|
13
|
+
name, separator, codec = part.partition('=')
|
|
14
|
+
next unless separator == "=" && name.strip.downcase == "codecs"
|
|
15
|
+
|
|
16
|
+
codec = codec.strip
|
|
17
|
+
if codec.starts_with?('"') && codec.ends_with?('"')
|
|
18
|
+
codec = codec[1..-2]
|
|
19
|
+
end
|
|
20
|
+
codec
|
|
21
|
+
end.first?
|
|
22
|
+
|
|
23
|
+
new(type, subtype, codecs)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(@type : String, @subtype : String, @codecs : String? = nil)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def audio? : Bool
|
|
30
|
+
@type == "audio"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def video? : Bool
|
|
34
|
+
@type == "video"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def empty? : Bool
|
|
38
|
+
@type.empty? || @subtype.empty?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def container : String?
|
|
42
|
+
return nil if @subtype.empty?
|
|
43
|
+
|
|
44
|
+
@subtype == "mpeg" ? "mp3" : @subtype
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def audio_codec : String?
|
|
48
|
+
return @codecs if @codecs
|
|
49
|
+
return nil if @subtype.empty?
|
|
50
|
+
|
|
51
|
+
@subtype == "mpeg" ? "mp3" : @subtype
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def codec_list : Array(String)
|
|
55
|
+
@codecs.try(&.split(',').map(&.strip).reject(&.empty?)) || [] of String
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "path"
|
|
2
|
+
|
|
3
|
+
require "./errors"
|
|
4
|
+
|
|
5
|
+
module Rill::OutputFileName
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
MAX_BYTES = 180
|
|
9
|
+
|
|
10
|
+
INVALID_CHARACTERS = /[<>:"\/\\|?*\x00-\x1f]/
|
|
11
|
+
LEADING_DOTS_OR_SPACES = /\A[.\s]+/
|
|
12
|
+
TRAILING_DOTS_OR_SPACES = /[.\s]+\z/
|
|
13
|
+
WHITESPACE = /\s+/
|
|
14
|
+
VALID_EXTENSION = /\A[a-z0-9]+\z/
|
|
15
|
+
|
|
16
|
+
RESERVED_NAMES = %w(
|
|
17
|
+
con prn aux nul
|
|
18
|
+
com1 com2 com3 com4 com5 com6 com7 com8 com9
|
|
19
|
+
lpt1 lpt2 lpt3 lpt4 lpt5 lpt6 lpt7 lpt8 lpt9
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def from_title(title : String, extension : String? = nil) : String
|
|
23
|
+
extension = normalize_extension(extension)
|
|
24
|
+
limit = MAX_BYTES - (extension ? extension.bytesize + 1 : 0)
|
|
25
|
+
base = sanitize(title, limit)
|
|
26
|
+
|
|
27
|
+
if extension && !base.downcase.ends_with?(".#{extension}")
|
|
28
|
+
base = "#{base}.#{extension}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
reserved_name?(base) ? "_#{base}" : base
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def resolve(directory : String | Path, title : String, extension : String? = nil) : Path
|
|
35
|
+
resolve_path(directory, from_title(title, extension))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def resolve_path(directory : String | Path, file_name : String) : Path
|
|
39
|
+
dir = Path.new(directory.to_s).expand
|
|
40
|
+
target = (dir / file_name).expand
|
|
41
|
+
|
|
42
|
+
unless target.parent == dir
|
|
43
|
+
raise InvalidOutputPathError.new(target.to_s, dir.to_s)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
target
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private def sanitize(title : String, limit : Int32) : String
|
|
50
|
+
value = title.gsub(INVALID_CHARACTERS, "_")
|
|
51
|
+
value = value.gsub(WHITESPACE, " ")
|
|
52
|
+
value = value.sub(LEADING_DOTS_OR_SPACES, "")
|
|
53
|
+
value = value.sub(TRAILING_DOTS_OR_SPACES, "")
|
|
54
|
+
value = truncate(value, limit)
|
|
55
|
+
value = value.sub(TRAILING_DOTS_OR_SPACES, "")
|
|
56
|
+
value.empty? ? "download" : value
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private def truncate(value : String, limit : Int32) : String
|
|
60
|
+
return value if value.bytesize <= limit
|
|
61
|
+
|
|
62
|
+
builder = String::Builder.new
|
|
63
|
+
bytes = 0
|
|
64
|
+
|
|
65
|
+
value.each_char do |char|
|
|
66
|
+
size = char.bytesize
|
|
67
|
+
break if bytes + size > limit
|
|
68
|
+
|
|
69
|
+
builder << char
|
|
70
|
+
bytes += size
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
builder.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private def normalize_extension(extension : String?) : String?
|
|
77
|
+
return nil unless extension
|
|
78
|
+
|
|
79
|
+
value = extension.lchop('.').downcase
|
|
80
|
+
return nil if value.empty?
|
|
81
|
+
return nil if value.bytesize > 16
|
|
82
|
+
return nil unless VALID_EXTENSION.matches?(value)
|
|
83
|
+
|
|
84
|
+
value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private def reserved_name?(value : String) : Bool
|
|
88
|
+
base = value.split('.').first? || value
|
|
89
|
+
RESERVED_NAMES.includes?(base.downcase)
|
|
90
|
+
end
|
|
91
|
+
end
|