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.
Files changed (82) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +64 -2
  4. package/binding.gyp +31 -0
  5. package/index.d.ts +45 -0
  6. package/index.js +112 -0
  7. package/native/darwin-arm64/librill.dylib +0 -0
  8. package/native/darwin-arm64/rill-media +0 -0
  9. package/native/darwin-arm64/rill_node.node +0 -0
  10. package/native/darwin-x64/librill.dylib +0 -0
  11. package/native/darwin-x64/rill-media +0 -0
  12. package/native/darwin-x64/rill_node.node +0 -0
  13. package/native/linux-arm64-gnu/librill.so +0 -0
  14. package/native/linux-arm64-gnu/rill-media +0 -0
  15. package/native/linux-arm64-gnu/rill_node.node +0 -0
  16. package/native/linux-x64-gnu/librill.so +0 -0
  17. package/native/linux-x64-gnu/rill-media +0 -0
  18. package/native/linux-x64-gnu/rill_node.node +0 -0
  19. package/native-src/include/rill.h +90 -0
  20. package/native-src/include/rill_media.h +22 -0
  21. package/native-src/shard.yml +9 -0
  22. package/native-src/src/c_api.cr +194 -0
  23. package/native-src/src/cli.cr +637 -0
  24. package/native-src/src/rill/c_api/job.cr +336 -0
  25. package/native-src/src/rill/c_api/memory.cr +32 -0
  26. package/native-src/src/rill/c_api/status.cr +25 -0
  27. package/native-src/src/rill/c_api/sync.cr +125 -0
  28. package/native-src/src/rill/c_api/worker_pool.cr +403 -0
  29. package/native-src/src/rill/cancellation.cr +29 -0
  30. package/native-src/src/rill/client.cr +52 -0
  31. package/native-src/src/rill/cookie_jar.cr +130 -0
  32. package/native-src/src/rill/dash/manifest.cr +279 -0
  33. package/native-src/src/rill/dash/parser.cr +227 -0
  34. package/native-src/src/rill/download_options.cr +96 -0
  35. package/native-src/src/rill/download_plan.cr +27 -0
  36. package/native-src/src/rill/download_progress.cr +16 -0
  37. package/native-src/src/rill/downloader.cr +662 -0
  38. package/native-src/src/rill/errors.cr +155 -0
  39. package/native-src/src/rill/extractor.cr +18 -0
  40. package/native-src/src/rill/extractor_registry.cr +98 -0
  41. package/native-src/src/rill/extractors/bandcamp.cr +265 -0
  42. package/native-src/src/rill/extractors/dash.cr +77 -0
  43. package/native-src/src/rill/extractors/direct_media.cr +73 -0
  44. package/native-src/src/rill/extractors/generic.cr +441 -0
  45. package/native-src/src/rill/extractors/hls.cr +202 -0
  46. package/native-src/src/rill/extractors/internet_archive.cr +180 -0
  47. package/native-src/src/rill/extractors/m3u.cr +81 -0
  48. package/native-src/src/rill/extractors/media_ccc_de.cr +219 -0
  49. package/native-src/src/rill/extractors/mixcloud.cr +495 -0
  50. package/native-src/src/rill/extractors/peer_tube.cr +226 -0
  51. package/native-src/src/rill/extractors/rumble.cr +325 -0
  52. package/native-src/src/rill/extractors/soundcloud.cr +553 -0
  53. package/native-src/src/rill/extractors/spotify.cr +184 -0
  54. package/native-src/src/rill/extractors/twitch_clip.cr +273 -0
  55. package/native-src/src/rill/extractors/youtube.cr +520 -0
  56. package/native-src/src/rill/format.cr +82 -0
  57. package/native-src/src/rill/format_selector.cr +143 -0
  58. package/native-src/src/rill/hls/parser.cr +257 -0
  59. package/native-src/src/rill/hls/playlist.cr +100 -0
  60. package/native-src/src/rill/http_client.cr +503 -0
  61. package/native-src/src/rill/http_url.cr +28 -0
  62. package/native-src/src/rill/json_value.cr +66 -0
  63. package/native-src/src/rill/media.cr +151 -0
  64. package/native-src/src/rill/media_muxer.cr +333 -0
  65. package/native-src/src/rill/mime_type.cr +58 -0
  66. package/native-src/src/rill/output_file_name.cr +91 -0
  67. package/native-src/src/rill/playlist.cr +109 -0
  68. package/native-src/src/rill/request_options.cr +179 -0
  69. package/native-src/src/rill/segment_assembly.cr +70 -0
  70. package/native-src/src/rill/timestamp.cr +34 -0
  71. package/native-src/src/rill/version.cr +3 -0
  72. package/native-src/src/rill.cr +47 -0
  73. package/package.json +48 -4
  74. package/scripts/build-native.js +46 -0
  75. package/scripts/install-gyp.js +10 -0
  76. package/scripts/install.js +19 -0
  77. package/scripts/lib.js +1 -0
  78. package/scripts/libdir.js +23 -0
  79. package/scripts/package-native.js +35 -0
  80. package/scripts/platform.js +41 -0
  81. package/scripts/prepack.js +47 -0
  82. 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