youtube-rb 0.2.0 → 0.3.0
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.
- checksums.yaml +4 -4
- data/README.md +149 -429
- data/lib/youtube-rb/client.rb +5 -2
- data/lib/youtube-rb/downloader.rb +64 -456
- data/lib/youtube-rb/options.rb +3 -7
- data/lib/youtube-rb/version.rb +1 -1
- data/lib/youtube-rb.rb +0 -2
- data/spec/client_spec.rb +9 -16
- data/spec/download_with_mocks_spec.rb +8 -18
- data/spec/downloader_spec.rb +21 -114
- data/spec/fixtures/rickroll_full_info.json +785 -23
- data/spec/integration/ytdlp_integration_spec.rb +1 -12
- data/spec/real_download_spec.rb +1 -30
- data/spec/support/mocking_helper.rb +103 -8
- data/spec/youtube_rb_spec.rb +2 -18
- data/youtube-rb.gemspec +2 -2
- metadata +4 -5
- data/lib/youtube-rb/extractor.rb +0 -425
data/lib/youtube-rb/client.rb
CHANGED
|
@@ -10,8 +10,11 @@ module YoutubeRb
|
|
|
10
10
|
# @param url [String] Video URL
|
|
11
11
|
# @return [VideoInfo] Video information object
|
|
12
12
|
def info(url)
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
ytdlp = YtdlpWrapper.new(@options)
|
|
14
|
+
info_data = ytdlp.extract_info(url)
|
|
15
|
+
VideoInfo.new(info_data)
|
|
16
|
+
rescue YtdlpWrapper::YtdlpError => e
|
|
17
|
+
raise StandardError, "Failed to extract video info: #{e.message}"
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
# Download full video
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
require 'open3'
|
|
2
2
|
require 'fileutils'
|
|
3
|
-
require 'faraday'
|
|
4
3
|
|
|
5
4
|
module YoutubeRb
|
|
6
5
|
class Downloader
|
|
@@ -11,11 +10,12 @@ module YoutubeRb
|
|
|
11
10
|
def initialize(url, options = Options.new)
|
|
12
11
|
@url = url
|
|
13
12
|
@options = options.is_a?(Options) ? options : Options.new(**options)
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
begin
|
|
14
|
+
@ytdlp_wrapper = YtdlpWrapper.new(@options)
|
|
15
|
+
rescue YtdlpWrapper::YtdlpNotFoundError => e
|
|
16
|
+
raise DownloadError, "yt-dlp is required. #{e.message}"
|
|
17
|
+
end
|
|
16
18
|
@video_info = nil
|
|
17
|
-
@tried_ytdlp = false
|
|
18
|
-
@tried_ruby = false
|
|
19
19
|
@cached_video_path = nil # For caching full video when downloading multiple segments
|
|
20
20
|
end
|
|
21
21
|
|
|
@@ -23,12 +23,12 @@ module YoutubeRb
|
|
|
23
23
|
def download
|
|
24
24
|
ensure_output_directory
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
log "Downloading video with yt-dlp"
|
|
27
|
+
output_file = ytdlp_wrapper.download(@url)
|
|
28
|
+
log "Downloaded successfully: #{output_file}"
|
|
29
|
+
output_file
|
|
30
|
+
rescue YtdlpWrapper::YtdlpError => e
|
|
31
|
+
raise DownloadError, "Download failed: #{e.message}"
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# Download video segment (time range)
|
|
@@ -42,12 +42,12 @@ module YoutubeRb
|
|
|
42
42
|
|
|
43
43
|
ensure_output_directory
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
log "Downloading segment with yt-dlp: #{start_time}-#{end_time}s"
|
|
46
|
+
output_file = ytdlp_wrapper.download_segment(@url, start_time, end_time, output_file)
|
|
47
|
+
log "Downloaded segment successfully: #{output_file}"
|
|
48
|
+
output_file
|
|
49
|
+
rescue YtdlpWrapper::YtdlpError => e
|
|
50
|
+
raise DownloadError, "Segment download failed: #{e.message}"
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
# Download multiple video segments (batch processing)
|
|
@@ -73,127 +73,25 @@ module YoutubeRb
|
|
|
73
73
|
|
|
74
74
|
ensure_output_directory
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
unless ytdlp_available?
|
|
78
|
-
raise DownloadError, "yt-dlp is required for batch segment downloads. Please install yt-dlp."
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
download_segments_with_ytdlp(segments)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# Download only subtitles
|
|
85
|
-
def download_subtitles_only(langs = nil)
|
|
86
|
-
ensure_output_directory
|
|
87
|
-
@video_info = @extractor.extract_info
|
|
88
|
-
|
|
89
|
-
langs ||= @options.subtitle_langs
|
|
90
|
-
download_subtitles(langs)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
# Get video information without downloading
|
|
94
|
-
def info
|
|
95
|
-
@video_info ||= @extractor.extract_info
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
private
|
|
99
|
-
|
|
100
|
-
def should_use_ytdlp?
|
|
101
|
-
# Use yt-dlp if:
|
|
102
|
-
# 1. Explicitly requested via options
|
|
103
|
-
# 2. yt-dlp is available
|
|
104
|
-
if @options.use_ytdlp && ytdlp_available?
|
|
105
|
-
return true
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# Don't use yt-dlp if explicitly disabled
|
|
109
|
-
if @options.use_ytdlp == false
|
|
110
|
-
return false
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
# Default: use yt-dlp if available for better reliability
|
|
114
|
-
ytdlp_available?
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
def ytdlp_available?
|
|
118
|
-
@ytdlp_available ||= YtdlpWrapper.available?
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def ytdlp_wrapper
|
|
122
|
-
@ytdlp_wrapper ||= YtdlpWrapper.new(@options)
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def download_with_ytdlp
|
|
126
|
-
log "Using yt-dlp backend for download"
|
|
127
|
-
@tried_ytdlp = true
|
|
128
|
-
|
|
129
|
-
begin
|
|
130
|
-
output_file = ytdlp_wrapper.download(@url)
|
|
131
|
-
log "Downloaded successfully with yt-dlp: #{output_file}"
|
|
132
|
-
output_file
|
|
133
|
-
rescue YtdlpWrapper::YtdlpError => e
|
|
134
|
-
handle_ytdlp_error(e)
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def download_with_ruby
|
|
139
|
-
log "Using pure Ruby backend for download"
|
|
140
|
-
@tried_ruby = true
|
|
141
|
-
|
|
142
|
-
begin
|
|
143
|
-
@video_info = @extractor.extract_info
|
|
144
|
-
|
|
145
|
-
output_file = generate_output_path(@video_info)
|
|
146
|
-
|
|
147
|
-
if @options.extract_audio
|
|
148
|
-
download_audio(output_file)
|
|
149
|
-
else
|
|
150
|
-
download_video(output_file)
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
download_subtitles if @options.write_subtitles || @options.write_auto_sub
|
|
154
|
-
download_metadata if @options.write_info_json
|
|
155
|
-
download_thumbnail if @options.write_thumbnail
|
|
156
|
-
download_description if @options.write_description
|
|
157
|
-
|
|
158
|
-
output_file
|
|
159
|
-
rescue => e
|
|
160
|
-
handle_ruby_error(e)
|
|
161
|
-
end
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
def download_segment_with_ytdlp(start_time, end_time, output_file)
|
|
165
|
-
log "Using yt-dlp backend for segment download"
|
|
166
|
-
|
|
167
|
-
output_file = ytdlp_wrapper.download_segment(@url, start_time, end_time, output_file)
|
|
168
|
-
log "Downloaded segment successfully with yt-dlp: #{output_file}"
|
|
169
|
-
output_file
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def download_segments_with_ytdlp(segments)
|
|
173
|
-
log "Using yt-dlp backend for batch segment download (optimized: 1 download + local segmentation)"
|
|
76
|
+
log "Batch downloading #{segments.size} segments (optimized: 1 download + local segmentation)"
|
|
174
77
|
|
|
175
78
|
output_files = []
|
|
176
79
|
|
|
177
80
|
begin
|
|
178
81
|
# Download full video once using yt-dlp (handles all YouTube protection)
|
|
179
|
-
full_video_path =
|
|
82
|
+
full_video_path = get_full_video_for_segmentation
|
|
180
83
|
|
|
181
84
|
# Extract all segments locally using FFmpeg (fast and efficient)
|
|
182
85
|
segments.each_with_index do |seg, idx|
|
|
183
86
|
start_time = seg[:start]
|
|
184
87
|
end_time = seg[:end]
|
|
185
|
-
output_file = seg[:output_file] || generate_segment_output_path(
|
|
88
|
+
output_file = seg[:output_file] || generate_segment_output_path(start_time, end_time)
|
|
186
89
|
|
|
187
90
|
log "Extracting segment #{idx + 1}/#{segments.size}: #{start_time}-#{end_time}s"
|
|
188
91
|
|
|
189
|
-
# Extract segment using ffmpeg
|
|
92
|
+
# Extract segment using ffmpeg
|
|
190
93
|
extract_segment(full_video_path, output_file, start_time, end_time)
|
|
191
94
|
|
|
192
|
-
# Download subtitles for segment if requested
|
|
193
|
-
if @options.write_subtitles || @options.write_auto_sub
|
|
194
|
-
download_subtitles_for_segment(start_time, end_time)
|
|
195
|
-
end
|
|
196
|
-
|
|
197
95
|
output_files << output_file
|
|
198
96
|
end
|
|
199
97
|
ensure
|
|
@@ -202,151 +100,55 @@ module YoutubeRb
|
|
|
202
100
|
end
|
|
203
101
|
|
|
204
102
|
output_files
|
|
103
|
+
rescue YtdlpWrapper::YtdlpError => e
|
|
104
|
+
raise DownloadError, "Batch segment download failed: #{e.message}"
|
|
205
105
|
end
|
|
206
106
|
|
|
207
|
-
|
|
208
|
-
def
|
|
209
|
-
|
|
107
|
+
# Download only subtitles
|
|
108
|
+
def download_subtitles_only(langs = nil)
|
|
109
|
+
ensure_output_directory
|
|
210
110
|
|
|
211
|
-
#
|
|
212
|
-
|
|
213
|
-
if fallback
|
|
214
|
-
log "Falling back to pure Ruby implementation"
|
|
215
|
-
return fallback.call
|
|
216
|
-
else
|
|
217
|
-
log "Falling back to pure Ruby implementation"
|
|
218
|
-
return download_with_ruby
|
|
219
|
-
end
|
|
220
|
-
end
|
|
111
|
+
# Extract info to get video details
|
|
112
|
+
@video_info ||= info
|
|
221
113
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
def handle_ruby_error(error)
|
|
226
|
-
log "Pure Ruby error: #{error.message}"
|
|
114
|
+
# Temporarily enable subtitle options
|
|
115
|
+
original_write_subtitles = @options.write_subtitles
|
|
116
|
+
original_subtitle_langs = @options.subtitle_langs
|
|
227
117
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
# 2. ytdlp_fallback is enabled
|
|
231
|
-
# 3. yt-dlp is available
|
|
232
|
-
# 4. Haven't tried yt-dlp yet
|
|
233
|
-
if @options.ytdlp_fallback && ytdlp_available? && !@tried_ytdlp
|
|
234
|
-
if error.message.include?('403') || error.is_a?(Extractor::ExtractionError)
|
|
235
|
-
log "Falling back to yt-dlp"
|
|
236
|
-
return download_with_ytdlp
|
|
237
|
-
end
|
|
238
|
-
end
|
|
118
|
+
@options.write_subtitles = true
|
|
119
|
+
@options.subtitle_langs = langs || @options.subtitle_langs
|
|
239
120
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def download_video(output_file)
|
|
248
|
-
# Always use HTTP download (pure Ruby)
|
|
249
|
-
download_with_http(output_file)
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def download_audio(output_file)
|
|
253
|
-
base_output = output_file.sub(/\.[^.]+$/, '')
|
|
254
|
-
|
|
255
|
-
# Download video first, then extract audio with FFmpeg
|
|
256
|
-
temp_video = generate_temp_path
|
|
257
|
-
download_with_http(temp_video)
|
|
258
|
-
extract_audio(temp_video, "#{base_output}.#{@options.audio_format}")
|
|
259
|
-
File.delete(temp_video) if File.exist?(temp_video)
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
def download_with_http(output_file)
|
|
263
|
-
format = @video_info.best_format
|
|
264
|
-
raise DownloadError, "No suitable format found" unless format
|
|
265
|
-
|
|
266
|
-
url = format[:url]
|
|
267
|
-
raise DownloadError, "No URL found in format" unless url
|
|
268
|
-
|
|
269
|
-
puts "Downloading from: #{url[0..80]}..." if @options.respond_to?(:verbose) && @options.verbose
|
|
270
|
-
|
|
271
|
-
# Use streaming download with progress
|
|
272
|
-
downloaded = 0
|
|
273
|
-
File.open(output_file, 'wb') do |file|
|
|
274
|
-
response = http_client.get(url) do |req|
|
|
275
|
-
req.options.on_data = Proc.new do |chunk, overall_received_bytes|
|
|
276
|
-
file.write(chunk)
|
|
277
|
-
downloaded = overall_received_bytes
|
|
278
|
-
end
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
unless response.success?
|
|
282
|
-
raise DownloadError, "HTTP download failed with status #{response.status}"
|
|
283
|
-
end
|
|
121
|
+
begin
|
|
122
|
+
log "Downloading subtitles only"
|
|
123
|
+
ytdlp_wrapper.download(@url)
|
|
124
|
+
ensure
|
|
125
|
+
# Restore original options
|
|
126
|
+
@options.write_subtitles = original_write_subtitles
|
|
127
|
+
@options.subtitle_langs = original_subtitle_langs
|
|
284
128
|
end
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
rescue Faraday::Error => e
|
|
288
|
-
raise DownloadError, "Network error during download: #{e.message}"
|
|
129
|
+
rescue YtdlpWrapper::YtdlpError => e
|
|
130
|
+
raise DownloadError, "Subtitle download failed: #{e.message}"
|
|
289
131
|
end
|
|
290
132
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
next unless subtitle_data
|
|
298
|
-
|
|
299
|
-
subtitle_data.each do |sub|
|
|
300
|
-
download_subtitle_file(sub, lang)
|
|
301
|
-
end
|
|
133
|
+
# Get video information without downloading
|
|
134
|
+
def info
|
|
135
|
+
@video_info ||= begin
|
|
136
|
+
log "Extracting video info with yt-dlp"
|
|
137
|
+
info_data = ytdlp_wrapper.extract_info(@url)
|
|
138
|
+
VideoInfo.new(info_data)
|
|
302
139
|
end
|
|
140
|
+
rescue YtdlpWrapper::YtdlpError => e
|
|
141
|
+
raise DownloadError, "Failed to extract video info: #{e.message}"
|
|
303
142
|
end
|
|
304
143
|
|
|
305
|
-
|
|
306
|
-
langs = @options.subtitle_langs
|
|
307
|
-
return if @video_info.subtitles.empty?
|
|
308
|
-
|
|
309
|
-
langs.each do |lang|
|
|
310
|
-
subtitle_data = @video_info.get_subtitle(lang)
|
|
311
|
-
next unless subtitle_data
|
|
312
|
-
|
|
313
|
-
subtitle_data.each do |sub|
|
|
314
|
-
output_file = generate_subtitle_segment_path(lang, start_time, end_time)
|
|
315
|
-
download_and_trim_subtitle(sub, output_file, start_time, end_time)
|
|
316
|
-
end
|
|
317
|
-
end
|
|
318
|
-
end
|
|
144
|
+
private
|
|
319
145
|
|
|
320
|
-
def
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
begin
|
|
324
|
-
response = http_client.get(subtitle[:url])
|
|
325
|
-
|
|
326
|
-
unless response.success?
|
|
327
|
-
warn "Failed to download subtitle: HTTP #{response.status}"
|
|
328
|
-
return
|
|
329
|
-
end
|
|
330
|
-
|
|
331
|
-
File.write(output_file, response.body)
|
|
332
|
-
|
|
333
|
-
# Convert to requested format if different
|
|
334
|
-
if @options.subtitle_format != subtitle[:ext]
|
|
335
|
-
convert_subtitle_format(output_file, @options.subtitle_format)
|
|
336
|
-
end
|
|
337
|
-
rescue => e
|
|
338
|
-
warn "Failed to download subtitle for #{lang}: #{e.message}"
|
|
339
|
-
end
|
|
146
|
+
def ytdlp_wrapper
|
|
147
|
+
@ytdlp_wrapper
|
|
340
148
|
end
|
|
341
149
|
|
|
342
|
-
def
|
|
343
|
-
|
|
344
|
-
content = response.body
|
|
345
|
-
|
|
346
|
-
# Parse and trim subtitle based on time range
|
|
347
|
-
trimmed_content = trim_subtitle_content(content, start_time, end_time, subtitle[:ext])
|
|
348
|
-
|
|
349
|
-
File.write(output_file, trimmed_content)
|
|
150
|
+
def log(message)
|
|
151
|
+
puts "[YoutubeRb] #{message}" if @options.verbose
|
|
350
152
|
end
|
|
351
153
|
|
|
352
154
|
def extract_segment(input_file, output_file, start_time, end_time)
|
|
@@ -356,6 +158,8 @@ module YoutubeRb
|
|
|
356
158
|
|
|
357
159
|
duration = end_time - start_time
|
|
358
160
|
|
|
161
|
+
# Use stream copy mode for fast extraction (cuts at keyframes)
|
|
162
|
+
# For precise mode, would need to re-encode (much slower)
|
|
359
163
|
cmd = [
|
|
360
164
|
'ffmpeg',
|
|
361
165
|
'-i', input_file,
|
|
@@ -365,202 +169,23 @@ module YoutubeRb
|
|
|
365
169
|
'-avoid_negative_ts', '1',
|
|
366
170
|
output_file,
|
|
367
171
|
'-y'
|
|
368
|
-
]
|
|
172
|
+
]
|
|
369
173
|
|
|
370
|
-
stdout, stderr, status = Open3.capture3(cmd)
|
|
174
|
+
stdout, stderr, status = Open3.capture3(*cmd)
|
|
371
175
|
|
|
372
176
|
unless status.success?
|
|
373
177
|
raise DownloadError, "Segment extraction failed: #{stderr}"
|
|
374
178
|
end
|
|
375
179
|
end
|
|
376
180
|
|
|
377
|
-
def
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
end
|
|
381
|
-
|
|
382
|
-
cmd = [
|
|
383
|
-
'ffmpeg',
|
|
384
|
-
'-i', input_file,
|
|
385
|
-
'-vn',
|
|
386
|
-
'-acodec', audio_codec_for_format(@options.audio_format),
|
|
387
|
-
'-ab', "#{@options.audio_quality}k",
|
|
388
|
-
output_file,
|
|
389
|
-
'-y'
|
|
390
|
-
].join(' ')
|
|
391
|
-
|
|
392
|
-
stdout, stderr, status = Open3.capture3(cmd)
|
|
393
|
-
|
|
394
|
-
unless status.success?
|
|
395
|
-
raise DownloadError, "Audio extraction failed: #{stderr}"
|
|
396
|
-
end
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
def trim_subtitle_content(content, start_time, end_time, format)
|
|
400
|
-
case format
|
|
401
|
-
when 'vtt', 'srt'
|
|
402
|
-
trim_vtt_or_srt(content, start_time, end_time)
|
|
403
|
-
else
|
|
404
|
-
content # Return as-is for unsupported formats
|
|
405
|
-
end
|
|
406
|
-
end
|
|
407
|
-
|
|
408
|
-
def trim_vtt_or_srt(content, start_time, end_time)
|
|
409
|
-
lines = content.split("\n")
|
|
410
|
-
result = []
|
|
411
|
-
current_block = []
|
|
412
|
-
in_cue = false
|
|
413
|
-
|
|
414
|
-
lines.each do |line|
|
|
415
|
-
if match_data = line.match(/(\d{2}:\d{2}:\d{2}[.,]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[.,]\d{3})/)
|
|
416
|
-
# This is a timestamp line
|
|
417
|
-
cue_start = parse_subtitle_time(match_data[1])
|
|
418
|
-
cue_end = parse_subtitle_time(match_data[2])
|
|
419
|
-
|
|
420
|
-
if cue_end >= start_time && cue_start <= end_time
|
|
421
|
-
# Adjust timestamps relative to segment start
|
|
422
|
-
adjusted_start = [cue_start - start_time, 0].max
|
|
423
|
-
adjusted_end = [cue_end - start_time, end_time - start_time].min
|
|
424
|
-
|
|
425
|
-
current_block << format_subtitle_time(adjusted_start) + ' --> ' + format_subtitle_time(adjusted_end)
|
|
426
|
-
in_cue = true
|
|
427
|
-
else
|
|
428
|
-
in_cue = false
|
|
429
|
-
current_block = []
|
|
430
|
-
end
|
|
431
|
-
elsif in_cue
|
|
432
|
-
current_block << line
|
|
433
|
-
if line.strip.empty? && current_block.size > 1
|
|
434
|
-
result.concat(current_block)
|
|
435
|
-
current_block = []
|
|
436
|
-
end
|
|
437
|
-
elsif line.start_with?('WEBVTT') || line.start_with?('Kind:') || line.start_with?('Language:')
|
|
438
|
-
result << line
|
|
439
|
-
end
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
result.join("\n")
|
|
443
|
-
end
|
|
444
|
-
|
|
445
|
-
def parse_subtitle_time(time_str)
|
|
446
|
-
# Parse format: 00:00:10.500 or 00:00:10,500
|
|
447
|
-
parts = time_str.tr(',', '.').split(':')
|
|
448
|
-
hours = parts[0].to_i
|
|
449
|
-
minutes = parts[1].to_i
|
|
450
|
-
seconds_parts = parts[2].split('.')
|
|
451
|
-
seconds = seconds_parts[0].to_i
|
|
452
|
-
milliseconds = seconds_parts[1].to_i
|
|
453
|
-
|
|
454
|
-
hours * 3600 + minutes * 60 + seconds + milliseconds / 1000.0
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
def format_subtitle_time(seconds)
|
|
458
|
-
hours = (seconds / 3600).to_i
|
|
459
|
-
minutes = ((seconds % 3600) / 60).to_i
|
|
460
|
-
secs = (seconds % 60).to_i
|
|
461
|
-
millis = ((seconds % 1) * 1000).to_i
|
|
462
|
-
|
|
463
|
-
format("%02d:%02d:%02d.%03d", hours, minutes, secs, millis)
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
def download_metadata
|
|
467
|
-
output_file = generate_metadata_path
|
|
468
|
-
File.write(output_file, JSON.pretty_generate(@video_info.to_h))
|
|
469
|
-
end
|
|
470
|
-
|
|
471
|
-
def download_thumbnail
|
|
472
|
-
return unless @video_info.thumbnail
|
|
473
|
-
|
|
474
|
-
output_file = generate_thumbnail_path
|
|
475
|
-
response = http_client.get(@video_info.thumbnail)
|
|
476
|
-
File.write(output_file, response.body)
|
|
477
|
-
end
|
|
478
|
-
|
|
479
|
-
def download_description
|
|
480
|
-
return unless @video_info.description
|
|
481
|
-
|
|
482
|
-
output_file = generate_description_path
|
|
483
|
-
File.write(output_file, @video_info.description)
|
|
484
|
-
end
|
|
485
|
-
|
|
486
|
-
def convert_subtitle_format(input_file, target_format)
|
|
487
|
-
# Basic conversion support (can be extended)
|
|
488
|
-
return if File.extname(input_file) == ".#{target_format}"
|
|
489
|
-
|
|
490
|
-
output_file = input_file.sub(/\.[^.]+$/, ".#{target_format}")
|
|
491
|
-
|
|
492
|
-
# For now, just rename for compatible formats
|
|
493
|
-
# TODO: Add proper conversion logic for different formats
|
|
494
|
-
FileUtils.mv(input_file, output_file)
|
|
495
|
-
end
|
|
496
|
-
|
|
497
|
-
def audio_codec_for_format(format)
|
|
498
|
-
case format
|
|
499
|
-
when 'mp3'
|
|
500
|
-
'libmp3lame'
|
|
501
|
-
when 'aac', 'm4a'
|
|
502
|
-
'aac'
|
|
503
|
-
when 'opus'
|
|
504
|
-
'libopus'
|
|
505
|
-
when 'vorbis', 'ogg'
|
|
506
|
-
'libvorbis'
|
|
507
|
-
when 'flac'
|
|
508
|
-
'flac'
|
|
509
|
-
when 'wav'
|
|
510
|
-
'pcm_s16le'
|
|
511
|
-
else
|
|
512
|
-
'copy'
|
|
513
|
-
end
|
|
514
|
-
end
|
|
515
|
-
|
|
516
|
-
def generate_output_path(video_info)
|
|
517
|
-
template = @options.output_template
|
|
518
|
-
|
|
519
|
-
# Replace template variables
|
|
520
|
-
filename = template
|
|
521
|
-
.gsub('%(title)s', sanitize_filename(video_info.title))
|
|
522
|
-
.gsub('%(id)s', video_info.id)
|
|
523
|
-
.gsub('%(ext)s', @options.extract_audio ? @options.audio_format : (video_info.ext || 'mp4'))
|
|
524
|
-
.gsub('%(uploader)s', sanitize_filename(video_info.uploader || 'unknown'))
|
|
181
|
+
def generate_segment_output_path(start_time, end_time)
|
|
182
|
+
# Get video info for filename generation
|
|
183
|
+
video_info = info
|
|
525
184
|
|
|
526
|
-
File.join(@options.output_path, filename)
|
|
527
|
-
end
|
|
528
|
-
|
|
529
|
-
def generate_segment_output_path(video_info, start_time, end_time)
|
|
530
185
|
filename = "#{sanitize_filename(video_info.title)}-#{video_info.id}-segment-#{start_time}-#{end_time}.#{video_info.ext || 'mp4'}"
|
|
531
186
|
File.join(@options.output_path, filename)
|
|
532
187
|
end
|
|
533
188
|
|
|
534
|
-
def generate_subtitle_path(lang, ext)
|
|
535
|
-
filename = "#{sanitize_filename(@video_info.title)}-#{@video_info.id}.#{lang}.#{ext}"
|
|
536
|
-
File.join(@options.output_path, filename)
|
|
537
|
-
end
|
|
538
|
-
|
|
539
|
-
def generate_subtitle_segment_path(lang, start_time, end_time)
|
|
540
|
-
filename = "#{sanitize_filename(@video_info.title)}-#{@video_info.id}-segment-#{start_time}-#{end_time}.#{lang}.#{@options.subtitle_format}"
|
|
541
|
-
File.join(@options.output_path, filename)
|
|
542
|
-
end
|
|
543
|
-
|
|
544
|
-
def generate_metadata_path
|
|
545
|
-
filename = "#{sanitize_filename(@video_info.title)}-#{@video_info.id}.info.json"
|
|
546
|
-
File.join(@options.output_path, filename)
|
|
547
|
-
end
|
|
548
|
-
|
|
549
|
-
def generate_thumbnail_path
|
|
550
|
-
ext = File.extname(@video_info.thumbnail).split('?').first || '.jpg'
|
|
551
|
-
filename = "#{sanitize_filename(@video_info.title)}-#{@video_info.id}#{ext}"
|
|
552
|
-
File.join(@options.output_path, filename)
|
|
553
|
-
end
|
|
554
|
-
|
|
555
|
-
def generate_description_path
|
|
556
|
-
filename = "#{sanitize_filename(@video_info.title)}-#{@video_info.id}.description"
|
|
557
|
-
File.join(@options.output_path, filename)
|
|
558
|
-
end
|
|
559
|
-
|
|
560
|
-
def generate_temp_path
|
|
561
|
-
File.join(@options.output_path, ".temp_#{Time.now.to_i}_#{rand(1000)}.mp4")
|
|
562
|
-
end
|
|
563
|
-
|
|
564
189
|
def sanitize_filename(filename)
|
|
565
190
|
return 'video' if filename.nil? || filename.empty?
|
|
566
191
|
|
|
@@ -578,7 +203,7 @@ module YoutubeRb
|
|
|
578
203
|
duration >= @options.min_segment_duration && duration <= @options.max_segment_duration
|
|
579
204
|
end
|
|
580
205
|
|
|
581
|
-
def
|
|
206
|
+
def get_full_video_for_segmentation
|
|
582
207
|
# Return cached video if available
|
|
583
208
|
if @cached_video_path && File.exist?(@cached_video_path)
|
|
584
209
|
log "Using cached video: #{@cached_video_path}"
|
|
@@ -586,7 +211,7 @@ module YoutubeRb
|
|
|
586
211
|
end
|
|
587
212
|
|
|
588
213
|
# Extract video info first (needed for segment naming)
|
|
589
|
-
@video_info ||=
|
|
214
|
+
@video_info ||= info
|
|
590
215
|
|
|
591
216
|
# Download full video using yt-dlp
|
|
592
217
|
@cached_video_path = generate_cache_path
|
|
@@ -611,22 +236,5 @@ module YoutubeRb
|
|
|
611
236
|
def ffmpeg_available?
|
|
612
237
|
system('which ffmpeg > /dev/null 2>&1')
|
|
613
238
|
end
|
|
614
|
-
|
|
615
|
-
def http_client
|
|
616
|
-
@http_client ||= Faraday.new do |f|
|
|
617
|
-
f.request :retry, max: @options.retries, interval: 0.5, backoff_factor: 2
|
|
618
|
-
f.adapter Faraday.default_adapter
|
|
619
|
-
f.options.timeout = 600 # 10 minutes for large downloads
|
|
620
|
-
f.options.open_timeout = 30
|
|
621
|
-
|
|
622
|
-
f.headers['User-Agent'] = @options.user_agent || 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
623
|
-
f.headers['Accept'] = '*/*'
|
|
624
|
-
f.headers['Accept-Language'] = 'en-US,en;q=0.9'
|
|
625
|
-
f.headers['Referer'] = @options.referer if @options.referer
|
|
626
|
-
|
|
627
|
-
# Add range support for resuming downloads
|
|
628
|
-
f.headers['Range'] = 'bytes=0-' if @options.continue_download
|
|
629
|
-
end
|
|
630
|
-
end
|
|
631
239
|
end
|
|
632
240
|
end
|
data/lib/youtube-rb/options.rb
CHANGED
|
@@ -18,8 +18,8 @@ module YoutubeRb
|
|
|
18
18
|
attr_accessor :match_title, :reject_title, :date, :datebefore, :dateafter,
|
|
19
19
|
:min_views, :max_views, :no_playlist, :yes_playlist
|
|
20
20
|
|
|
21
|
-
#
|
|
22
|
-
attr_accessor :
|
|
21
|
+
# Logging
|
|
22
|
+
attr_accessor :verbose
|
|
23
23
|
|
|
24
24
|
# Segment cutting options
|
|
25
25
|
attr_accessor :segment_mode # :fast (default) or :precise
|
|
@@ -27,9 +27,7 @@ module YoutubeRb
|
|
|
27
27
|
attr_accessor :cache_full_video # cache full video for multiple segment extractions
|
|
28
28
|
|
|
29
29
|
def initialize(**options)
|
|
30
|
-
#
|
|
31
|
-
@use_ytdlp = options.fetch(:use_ytdlp, false)
|
|
32
|
-
@ytdlp_fallback = options.fetch(:ytdlp_fallback, true)
|
|
30
|
+
# Logging
|
|
33
31
|
@verbose = options.fetch(:verbose, false)
|
|
34
32
|
|
|
35
33
|
# Segment cutting options
|
|
@@ -119,8 +117,6 @@ module YoutubeRb
|
|
|
119
117
|
|
|
120
118
|
def to_h
|
|
121
119
|
{
|
|
122
|
-
use_ytdlp: @use_ytdlp,
|
|
123
|
-
ytdlp_fallback: @ytdlp_fallback,
|
|
124
120
|
verbose: @verbose,
|
|
125
121
|
segment_mode: @segment_mode,
|
|
126
122
|
min_segment_duration: @min_segment_duration,
|
data/lib/youtube-rb/version.rb
CHANGED