youtube-rb 0.2.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +703 -0
- data/Rakefile +6 -0
- data/bin/console +10 -0
- data/bin/setup +8 -0
- data/lib/youtube-rb/client.rb +160 -0
- data/lib/youtube-rb/downloader.rb +632 -0
- data/lib/youtube-rb/extractor.rb +425 -0
- data/lib/youtube-rb/options.rb +186 -0
- data/lib/youtube-rb/version.rb +3 -0
- data/lib/youtube-rb/video_info.rb +179 -0
- data/lib/youtube-rb/ytdlp_wrapper.rb +269 -0
- data/lib/youtube-rb.rb +69 -0
- data/spec/client_spec.rb +514 -0
- data/spec/download_with_mocks_spec.rb +216 -0
- data/spec/downloader_spec.rb +774 -0
- data/spec/fixtures/first_video_info.json +19 -0
- data/spec/fixtures/rickroll_full_info.json +73 -0
- data/spec/fixtures/rickroll_info.json +73 -0
- data/spec/fixtures/rickroll_segment_info.json +9 -0
- data/spec/integration/ytdlp_integration_spec.rb +109 -0
- data/spec/real_download_spec.rb +175 -0
- data/spec/spec_helper.rb +31 -0
- data/spec/support/fixtures_helper.rb +109 -0
- data/spec/support/mocking_helper.rb +21 -0
- data/spec/support/webmock_helper.rb +132 -0
- data/spec/youtube_rb_spec.rb +200 -0
- data/spec/ytdlp_wrapper_spec.rb +178 -0
- data/youtube-rb.gemspec +39 -0
- metadata +229 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
RSpec.describe YoutubeRb::Downloader do
|
|
2
|
+
let(:test_url) { 'https://www.youtube.com/watch?v=test123abc' }
|
|
3
|
+
let(:video_data) { sample_video_data }
|
|
4
|
+
let(:options) { YoutubeRb::Options.new(output_path: @test_output_dir) }
|
|
5
|
+
let(:downloader) { described_class.new(test_url, options) }
|
|
6
|
+
|
|
7
|
+
before do
|
|
8
|
+
mock_extractor(video_data)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
describe '#initialize' do
|
|
12
|
+
it 'creates downloader with URL and options object' do
|
|
13
|
+
dl = described_class.new(test_url, options)
|
|
14
|
+
|
|
15
|
+
expect(dl.url).to eq(test_url)
|
|
16
|
+
expect(dl.options).to be_a(YoutubeRb::Options)
|
|
17
|
+
expect(dl.options.output_path).to eq(@test_output_dir)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'creates downloader with URL and options hash' do
|
|
21
|
+
dl = described_class.new(test_url, output_path: '/tmp/test')
|
|
22
|
+
|
|
23
|
+
expect(dl.url).to eq(test_url)
|
|
24
|
+
expect(dl.options).to be_a(YoutubeRb::Options)
|
|
25
|
+
expect(dl.options.output_path).to eq('/tmp/test')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'accepts empty options' do
|
|
29
|
+
dl = described_class.new(test_url)
|
|
30
|
+
|
|
31
|
+
expect(dl.url).to eq(test_url)
|
|
32
|
+
expect(dl.options).to be_a(YoutubeRb::Options)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe '#info' do
|
|
37
|
+
it 'returns video information' do
|
|
38
|
+
info = downloader.info
|
|
39
|
+
|
|
40
|
+
expect(info).to be_a(YoutubeRb::VideoInfo)
|
|
41
|
+
expect(info.id).to eq('test123abc')
|
|
42
|
+
expect(info.title).to eq('Test Video Title')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'caches video info' do
|
|
46
|
+
info1 = downloader.info
|
|
47
|
+
info2 = downloader.info
|
|
48
|
+
|
|
49
|
+
expect(info1).to equal(info2)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'calls extractor only once' do
|
|
53
|
+
video_info = YoutubeRb::VideoInfo.new(video_data)
|
|
54
|
+
|
|
55
|
+
expect_any_instance_of(YoutubeRb::Extractor)
|
|
56
|
+
.to receive(:extract_info).once.and_return(video_info)
|
|
57
|
+
|
|
58
|
+
downloader.info
|
|
59
|
+
downloader.info
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe '#download' do
|
|
64
|
+
let(:video_url) { video_data['formats'].last['url'] } # Use 720p (highest quality)
|
|
65
|
+
|
|
66
|
+
before do
|
|
67
|
+
stub_video_download(video_url)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
context 'basic video download' do
|
|
71
|
+
it 'downloads video successfully' do
|
|
72
|
+
output_file = downloader.download
|
|
73
|
+
|
|
74
|
+
expect(output_file).to be_a(String)
|
|
75
|
+
expect(File.exist?(output_file)).to be true
|
|
76
|
+
expect(File.size(output_file)).to be > 0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'creates output directory' do
|
|
80
|
+
expect(Dir.exist?(@test_output_dir)).to be true
|
|
81
|
+
|
|
82
|
+
downloader.download
|
|
83
|
+
|
|
84
|
+
expect(Dir.exist?(@test_output_dir)).to be true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'uses output template for filename' do
|
|
88
|
+
custom_options = YoutubeRb::Options.new(
|
|
89
|
+
output_path: @test_output_dir,
|
|
90
|
+
output_template: 'video-%(id)s.%(ext)s'
|
|
91
|
+
)
|
|
92
|
+
dl = described_class.new(test_url, custom_options)
|
|
93
|
+
|
|
94
|
+
mock_extractor(video_data)
|
|
95
|
+
stub_video_download(video_url)
|
|
96
|
+
|
|
97
|
+
output_file = dl.download
|
|
98
|
+
|
|
99
|
+
expect(File.basename(output_file)).to eq('video-test123abc.mp4')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'sanitizes filename' do
|
|
103
|
+
bad_title_data = video_data.dup
|
|
104
|
+
bad_title_data['title'] = 'Test/Video:With*Bad?Chars'
|
|
105
|
+
|
|
106
|
+
mock_extractor(bad_title_data)
|
|
107
|
+
stub_video_download(video_url)
|
|
108
|
+
|
|
109
|
+
dl = described_class.new(
|
|
110
|
+
'https://www.youtube.com/watch?v=badchars',
|
|
111
|
+
options
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
output_file = dl.download
|
|
115
|
+
filename = File.basename(output_file)
|
|
116
|
+
|
|
117
|
+
expect(filename).not_to include('/', ':', '*', '?')
|
|
118
|
+
expect(filename).to include('_')
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
context 'with subtitles' do
|
|
123
|
+
before do
|
|
124
|
+
video_data['subtitles'].each do |lang, subs|
|
|
125
|
+
subs.each { |sub| stub_subtitle_download(sub['url']) }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it 'downloads subtitles when enabled' do
|
|
130
|
+
subtitle_options = YoutubeRb::Options.new(
|
|
131
|
+
output_path: @test_output_dir,
|
|
132
|
+
write_subtitles: true,
|
|
133
|
+
subtitle_langs: ['en']
|
|
134
|
+
)
|
|
135
|
+
dl = described_class.new(test_url, subtitle_options)
|
|
136
|
+
|
|
137
|
+
mock_extractor(video_data)
|
|
138
|
+
stub_video_download(video_url)
|
|
139
|
+
video_data['subtitles'].each do |lang, subs|
|
|
140
|
+
subs.each { |sub| stub_subtitle_download(sub['url']) }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
dl.download
|
|
144
|
+
|
|
145
|
+
subtitle_files = Dir.glob(File.join(@test_output_dir, '*.srt'))
|
|
146
|
+
expect(subtitle_files).not_to be_empty
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'skips subtitles when disabled' do
|
|
150
|
+
dl = described_class.new(test_url, options)
|
|
151
|
+
|
|
152
|
+
mock_extractor(video_data)
|
|
153
|
+
stub_video_download(video_url)
|
|
154
|
+
|
|
155
|
+
dl.download
|
|
156
|
+
|
|
157
|
+
subtitle_files = Dir.glob(File.join(@test_output_dir, '*.{srt,vtt}'))
|
|
158
|
+
expect(subtitle_files).to be_empty
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
context 'with metadata' do
|
|
163
|
+
before do
|
|
164
|
+
stub_thumbnail_download(video_data['thumbnail'])
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it 'writes info JSON when enabled' do
|
|
168
|
+
metadata_options = YoutubeRb::Options.new(
|
|
169
|
+
output_path: @test_output_dir,
|
|
170
|
+
write_info_json: true
|
|
171
|
+
)
|
|
172
|
+
dl = described_class.new(test_url, metadata_options)
|
|
173
|
+
|
|
174
|
+
mock_extractor(video_data)
|
|
175
|
+
stub_video_download(video_url)
|
|
176
|
+
stub_thumbnail_download(video_data['thumbnail'])
|
|
177
|
+
|
|
178
|
+
dl.download
|
|
179
|
+
|
|
180
|
+
json_files = Dir.glob(File.join(@test_output_dir, '*.info.json'))
|
|
181
|
+
expect(json_files).not_to be_empty
|
|
182
|
+
|
|
183
|
+
json_content = JSON.parse(File.read(json_files.first))
|
|
184
|
+
expect(json_content['id']).to eq('test123abc')
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'downloads thumbnail when enabled' do
|
|
188
|
+
thumbnail_options = YoutubeRb::Options.new(
|
|
189
|
+
output_path: @test_output_dir,
|
|
190
|
+
write_thumbnail: true
|
|
191
|
+
)
|
|
192
|
+
dl = described_class.new(test_url, thumbnail_options)
|
|
193
|
+
|
|
194
|
+
mock_extractor(video_data)
|
|
195
|
+
stub_video_download(video_url)
|
|
196
|
+
stub_thumbnail_download(video_data['thumbnail'])
|
|
197
|
+
|
|
198
|
+
dl.download
|
|
199
|
+
|
|
200
|
+
image_files = Dir.glob(File.join(@test_output_dir, '*.{jpg,jpeg,png,webp}'))
|
|
201
|
+
expect(image_files).not_to be_empty
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it 'writes description when enabled' do
|
|
205
|
+
desc_options = YoutubeRb::Options.new(
|
|
206
|
+
output_path: @test_output_dir,
|
|
207
|
+
write_description: true
|
|
208
|
+
)
|
|
209
|
+
dl = described_class.new(test_url, desc_options)
|
|
210
|
+
|
|
211
|
+
mock_extractor(video_data)
|
|
212
|
+
stub_video_download(video_url)
|
|
213
|
+
|
|
214
|
+
dl.download
|
|
215
|
+
|
|
216
|
+
desc_files = Dir.glob(File.join(@test_output_dir, '*.description'))
|
|
217
|
+
expect(desc_files).not_to be_empty
|
|
218
|
+
|
|
219
|
+
desc_content = File.read(desc_files.first)
|
|
220
|
+
expect(desc_content).to eq('This is a test video description')
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
context 'audio extraction' do
|
|
225
|
+
before do
|
|
226
|
+
allow_any_instance_of(described_class).to receive(:ffmpeg_available?).and_return(true)
|
|
227
|
+
allow(Open3).to receive(:capture3).and_return(['', '', double(success?: true)])
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
it 'extracts audio when enabled' do
|
|
231
|
+
audio_options = YoutubeRb::Options.new(
|
|
232
|
+
output_path: @test_output_dir,
|
|
233
|
+
extract_audio: true,
|
|
234
|
+
audio_format: 'mp3',
|
|
235
|
+
audio_quality: '192'
|
|
236
|
+
)
|
|
237
|
+
dl = described_class.new(test_url, audio_options)
|
|
238
|
+
|
|
239
|
+
mock_extractor(video_data)
|
|
240
|
+
stub_video_download(video_url)
|
|
241
|
+
|
|
242
|
+
output_file = dl.download
|
|
243
|
+
|
|
244
|
+
expect(output_file).to be_a(String)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
it 'raises error if ffmpeg not available' do
|
|
248
|
+
audio_options = YoutubeRb::Options.new(
|
|
249
|
+
output_path: @test_output_dir,
|
|
250
|
+
extract_audio: true
|
|
251
|
+
)
|
|
252
|
+
dl = described_class.new(test_url, audio_options)
|
|
253
|
+
|
|
254
|
+
mock_extractor(video_data)
|
|
255
|
+
stub_video_download(video_url)
|
|
256
|
+
|
|
257
|
+
allow_any_instance_of(described_class).to receive(:ffmpeg_available?).and_return(false)
|
|
258
|
+
|
|
259
|
+
expect {
|
|
260
|
+
dl.download
|
|
261
|
+
}.to raise_error(YoutubeRb::Downloader::DownloadError, /FFmpeg is required/)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
context 'error handling' do
|
|
266
|
+
it 'raises error when no formats available' do
|
|
267
|
+
no_format_data = video_data.dup
|
|
268
|
+
no_format_data['formats'] = []
|
|
269
|
+
|
|
270
|
+
mock_extractor(no_format_data)
|
|
271
|
+
|
|
272
|
+
dl = described_class.new(
|
|
273
|
+
'https://www.youtube.com/watch?v=noformat',
|
|
274
|
+
options
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
expect {
|
|
278
|
+
dl.download
|
|
279
|
+
}.to raise_error(YoutubeRb::Downloader::DownloadError, /No suitable format/)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
it 'raises error when format has no URL' do
|
|
283
|
+
bad_format_data = video_data.dup
|
|
284
|
+
bad_format_data['formats'] = [{ 'format_id' => '18', 'ext' => 'mp4' }]
|
|
285
|
+
|
|
286
|
+
mock_extractor(bad_format_data)
|
|
287
|
+
|
|
288
|
+
dl = described_class.new(
|
|
289
|
+
'https://www.youtube.com/watch?v=badformat',
|
|
290
|
+
options
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
expect {
|
|
294
|
+
dl.download
|
|
295
|
+
}.to raise_error(YoutubeRb::Downloader::DownloadError, /No URL found/)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
it 'handles HTTP download errors' do
|
|
299
|
+
# Disable yt-dlp fallback for this test
|
|
300
|
+
dl = YoutubeRb::Downloader.new(
|
|
301
|
+
test_url,
|
|
302
|
+
output_path: @test_output_dir,
|
|
303
|
+
use_ytdlp: false,
|
|
304
|
+
ytdlp_fallback: false
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
mock_extractor(video_data)
|
|
308
|
+
stub_request(:get, video_url).to_return(status: 403, body: 'Forbidden')
|
|
309
|
+
|
|
310
|
+
expect {
|
|
311
|
+
dl.download
|
|
312
|
+
}.to raise_error(YoutubeRb::Downloader::DownloadError, /HTTP download failed/)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
it 'handles network errors' do
|
|
316
|
+
mock_extractor(video_data)
|
|
317
|
+
stub_request(:get, video_url).to_raise(Faraday::ConnectionFailed)
|
|
318
|
+
|
|
319
|
+
expect {
|
|
320
|
+
downloader.download
|
|
321
|
+
}.to raise_error(YoutubeRb::Downloader::DownloadError, /Network error/)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
describe '#download_segment' do
|
|
327
|
+
before do
|
|
328
|
+
allow(YoutubeRb::YtdlpWrapper).to receive(:available?).and_return(true)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
it 'requires yt-dlp to be installed' do
|
|
332
|
+
allow(YoutubeRb::YtdlpWrapper).to receive(:available?).and_return(false)
|
|
333
|
+
|
|
334
|
+
expect {
|
|
335
|
+
downloader.download_segment(10, 30)
|
|
336
|
+
}.to raise_error(YoutubeRb::Downloader::DownloadError, /yt-dlp is required/)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
context 'with yt-dlp available' do
|
|
340
|
+
before do
|
|
341
|
+
# Mock ytdlp_wrapper.download_segment
|
|
342
|
+
allow_any_instance_of(YoutubeRb::YtdlpWrapper).to receive(:download_segment) do |_, url, start_time, end_time, output_file|
|
|
343
|
+
output_file || File.join(@test_output_dir, "segment-#{start_time}-#{end_time}.mp4")
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
it 'downloads video segment using yt-dlp' do
|
|
348
|
+
output_file = downloader.download_segment(10, 30)
|
|
349
|
+
|
|
350
|
+
expect(output_file).to be_a(String)
|
|
351
|
+
expect(output_file).to include('segment')
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
it 'accepts custom output file' do
|
|
355
|
+
custom_file = File.join(@test_output_dir, 'custom-segment.mp4')
|
|
356
|
+
output_file = downloader.download_segment(10, 30, custom_file)
|
|
357
|
+
|
|
358
|
+
expect(output_file).to eq(custom_file)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
it 'calls ytdlp wrapper download_segment' do
|
|
362
|
+
expect_any_instance_of(YoutubeRb::YtdlpWrapper)
|
|
363
|
+
.to receive(:download_segment)
|
|
364
|
+
.with(test_url, 10, 30, nil)
|
|
365
|
+
.and_return(File.join(@test_output_dir, 'segment-10-30.mp4'))
|
|
366
|
+
|
|
367
|
+
downloader.download_segment(10, 30)
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
it 'validates segment duration minimum (default 10s)' do
|
|
372
|
+
expect {
|
|
373
|
+
downloader.download_segment(0, 5)
|
|
374
|
+
}.to raise_error(ArgumentError, /between 10 and 60 seconds/)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
it 'validates segment duration maximum (default 60s)' do
|
|
378
|
+
expect {
|
|
379
|
+
downloader.download_segment(0, 70)
|
|
380
|
+
}.to raise_error(ArgumentError, /between 10 and 60 seconds/)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
it 'accepts custom min/max segment duration' do
|
|
384
|
+
custom_options = YoutubeRb::Options.new(
|
|
385
|
+
output_path: @test_output_dir,
|
|
386
|
+
min_segment_duration: 5,
|
|
387
|
+
max_segment_duration: 120
|
|
388
|
+
)
|
|
389
|
+
dl = described_class.new(test_url, custom_options)
|
|
390
|
+
|
|
391
|
+
allow(YoutubeRb::YtdlpWrapper).to receive(:available?).and_return(true)
|
|
392
|
+
allow_any_instance_of(YoutubeRb::YtdlpWrapper).to receive(:download_segment) do |_, url, start_time, end_time, output_file|
|
|
393
|
+
output_file || File.join(@test_output_dir, "segment-#{start_time}-#{end_time}.mp4")
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Should accept 5 second segment (custom minimum)
|
|
397
|
+
expect { dl.download_segment(0, 5) }.not_to raise_error
|
|
398
|
+
|
|
399
|
+
# Should accept 120 second segment (custom maximum)
|
|
400
|
+
expect { dl.download_segment(0, 120) }.not_to raise_error
|
|
401
|
+
|
|
402
|
+
# Should reject 4 second segment (below custom minimum)
|
|
403
|
+
expect { dl.download_segment(0, 4) }.to raise_error(ArgumentError, /between 5 and 120 seconds/)
|
|
404
|
+
|
|
405
|
+
# Should reject 121 second segment (above custom maximum)
|
|
406
|
+
expect { dl.download_segment(0, 121) }.to raise_error(ArgumentError, /between 5 and 120 seconds/)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
it 'validates start time less than end time' do
|
|
410
|
+
expect {
|
|
411
|
+
downloader.download_segment(30, 10)
|
|
412
|
+
}.to raise_error(ArgumentError, /Start time must be less than end time/)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
it 'accepts valid segment durations' do
|
|
416
|
+
allow(YoutubeRb::YtdlpWrapper).to receive(:available?).and_return(true)
|
|
417
|
+
allow_any_instance_of(YoutubeRb::YtdlpWrapper).to receive(:download_segment) do |_, url, start_time, end_time, output_file|
|
|
418
|
+
output_file || File.join(@test_output_dir, "segment-#{start_time}-#{end_time}.mp4")
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
expect {
|
|
422
|
+
downloader.download_segment(0, 10) # 10 seconds (minimum)
|
|
423
|
+
downloader.download_segment(0, 60) # 60 seconds (maximum)
|
|
424
|
+
downloader.download_segment(10, 45) # 35 seconds (in range)
|
|
425
|
+
}.not_to raise_error
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
describe '#download_segments' do
|
|
430
|
+
let(:video_url) { video_data['formats'].last['url'] }
|
|
431
|
+
|
|
432
|
+
before do
|
|
433
|
+
# Mock yt-dlp availability and wrapper
|
|
434
|
+
allow(YoutubeRb::YtdlpWrapper).to receive(:available?).and_return(true)
|
|
435
|
+
allow_any_instance_of(described_class).to receive(:ffmpeg_available?).and_return(true)
|
|
436
|
+
allow(Open3).to receive(:capture3).and_return(['', '', double(success?: true)])
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
it 'requires yt-dlp to be installed' do
|
|
440
|
+
allow(YoutubeRb::YtdlpWrapper).to receive(:available?).and_return(false)
|
|
441
|
+
|
|
442
|
+
segments = [{ start: 10, end: 30 }]
|
|
443
|
+
|
|
444
|
+
expect {
|
|
445
|
+
downloader.download_segments(segments)
|
|
446
|
+
}.to raise_error(YoutubeRb::Downloader::DownloadError, /yt-dlp is required/)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
context 'with yt-dlp available' do
|
|
450
|
+
before do
|
|
451
|
+
# Mock ytdlp_wrapper.download to simulate video download
|
|
452
|
+
allow_any_instance_of(YoutubeRb::YtdlpWrapper).to receive(:download) do |_, url, output_path|
|
|
453
|
+
# Create a fake video file
|
|
454
|
+
FileUtils.touch(output_path)
|
|
455
|
+
output_path
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
it 'downloads multiple segments using yt-dlp once + FFmpeg segmentation' do
|
|
460
|
+
segments = [
|
|
461
|
+
{ start: 10, end: 30 },
|
|
462
|
+
{ start: 60, end: 90 },
|
|
463
|
+
{ start: 120, end: 150 }
|
|
464
|
+
]
|
|
465
|
+
|
|
466
|
+
output_files = downloader.download_segments(segments)
|
|
467
|
+
|
|
468
|
+
expect(output_files).to be_an(Array)
|
|
469
|
+
expect(output_files.size).to eq(3)
|
|
470
|
+
output_files.each do |file|
|
|
471
|
+
expect(file).to be_a(String)
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
it 'downloads full video only once via yt-dlp' do
|
|
476
|
+
segments = [
|
|
477
|
+
{ start: 10, end: 30 },
|
|
478
|
+
{ start: 60, end: 90 },
|
|
479
|
+
{ start: 120, end: 150 }
|
|
480
|
+
]
|
|
481
|
+
|
|
482
|
+
# Should call yt-dlp download exactly once (not N times)
|
|
483
|
+
expect_any_instance_of(YoutubeRb::YtdlpWrapper)
|
|
484
|
+
.to receive(:download).once.and_call_original
|
|
485
|
+
|
|
486
|
+
downloader.download_segments(segments)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
it 'accepts custom output files for segments' do
|
|
490
|
+
segments = [
|
|
491
|
+
{ start: 10, end: 30, output_file: File.join(@test_output_dir, 'seg1.mp4') },
|
|
492
|
+
{ start: 60, end: 90, output_file: File.join(@test_output_dir, 'seg2.mp4') }
|
|
493
|
+
]
|
|
494
|
+
|
|
495
|
+
output_files = downloader.download_segments(segments)
|
|
496
|
+
|
|
497
|
+
expect(output_files[0]).to end_with('seg1.mp4')
|
|
498
|
+
expect(output_files[1]).to end_with('seg2.mp4')
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
it 'validates segments array' do
|
|
503
|
+
expect {
|
|
504
|
+
downloader.download_segments("not an array")
|
|
505
|
+
}.to raise_error(ArgumentError, /segments must be an Array/)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
it 'validates segments array is not empty' do
|
|
509
|
+
expect {
|
|
510
|
+
downloader.download_segments([])
|
|
511
|
+
}.to raise_error(ArgumentError, /segments array cannot be empty/)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
it 'validates each segment has start and end' do
|
|
515
|
+
segments = [
|
|
516
|
+
{ start: 10 } # missing end
|
|
517
|
+
]
|
|
518
|
+
|
|
519
|
+
expect {
|
|
520
|
+
downloader.download_segments(segments)
|
|
521
|
+
}.to raise_error(ArgumentError, /must be a Hash with :start and :end keys/)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
it 'validates segment durations' do
|
|
525
|
+
segments = [
|
|
526
|
+
{ start: 10, end: 30 }, # valid
|
|
527
|
+
{ start: 60, end: 65 } # invalid (5 seconds, below default minimum)
|
|
528
|
+
]
|
|
529
|
+
|
|
530
|
+
expect {
|
|
531
|
+
downloader.download_segments(segments)
|
|
532
|
+
}.to raise_error(ArgumentError, /Segment 1.*between 10 and 60 seconds/)
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
it 'validates start time less than end time' do
|
|
536
|
+
segments = [
|
|
537
|
+
{ start: 30, end: 10 } # invalid
|
|
538
|
+
]
|
|
539
|
+
|
|
540
|
+
expect {
|
|
541
|
+
downloader.download_segments(segments)
|
|
542
|
+
}.to raise_error(ArgumentError, /Segment 0.*start time must be less than end time/)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
context 'with caching enabled' do
|
|
546
|
+
before do
|
|
547
|
+
allow(YoutubeRb::YtdlpWrapper).to receive(:available?).and_return(true)
|
|
548
|
+
allow_any_instance_of(described_class).to receive(:ffmpeg_available?).and_return(true)
|
|
549
|
+
allow(Open3).to receive(:capture3).and_return(['', '', double(success?: true)])
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
it 'downloads full video once via yt-dlp and caches it' do
|
|
553
|
+
cache_options = YoutubeRb::Options.new(
|
|
554
|
+
output_path: @test_output_dir,
|
|
555
|
+
cache_full_video: true
|
|
556
|
+
)
|
|
557
|
+
dl = described_class.new(test_url, cache_options)
|
|
558
|
+
|
|
559
|
+
mock_extractor(video_data)
|
|
560
|
+
|
|
561
|
+
# Mock yt-dlp download
|
|
562
|
+
allow_any_instance_of(YoutubeRb::YtdlpWrapper).to receive(:download) do |_, url, output_path|
|
|
563
|
+
FileUtils.touch(output_path)
|
|
564
|
+
output_path
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
segments = [
|
|
568
|
+
{ start: 10, end: 30 },
|
|
569
|
+
{ start: 60, end: 90 }
|
|
570
|
+
]
|
|
571
|
+
|
|
572
|
+
dl.download_segments(segments)
|
|
573
|
+
|
|
574
|
+
# Verify cache file exists after download
|
|
575
|
+
cache_files = Dir.glob(File.join(@test_output_dir, '.cache_*'))
|
|
576
|
+
expect(cache_files).not_to be_empty
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
it 'cleans up cache when disabled' do
|
|
580
|
+
no_cache_options = YoutubeRb::Options.new(
|
|
581
|
+
output_path: @test_output_dir,
|
|
582
|
+
cache_full_video: false
|
|
583
|
+
)
|
|
584
|
+
dl = described_class.new(test_url, no_cache_options)
|
|
585
|
+
|
|
586
|
+
mock_extractor(video_data)
|
|
587
|
+
|
|
588
|
+
# Mock yt-dlp download
|
|
589
|
+
allow_any_instance_of(YoutubeRb::YtdlpWrapper).to receive(:download) do |_, url, output_path|
|
|
590
|
+
FileUtils.touch(output_path)
|
|
591
|
+
output_path
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
segments = [
|
|
595
|
+
{ start: 10, end: 30 },
|
|
596
|
+
{ start: 60, end: 90 }
|
|
597
|
+
]
|
|
598
|
+
|
|
599
|
+
dl.download_segments(segments)
|
|
600
|
+
|
|
601
|
+
# Verify cache file is deleted
|
|
602
|
+
cache_files = Dir.glob(File.join(@test_output_dir, '.cache_*'))
|
|
603
|
+
expect(cache_files).to be_empty
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
describe '#download_subtitles_only' do
|
|
609
|
+
before do
|
|
610
|
+
video_data['subtitles'].each do |lang, subs|
|
|
611
|
+
subs.each { |sub| stub_subtitle_download(sub['url']) }
|
|
612
|
+
end
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
it 'downloads subtitles without video' do
|
|
616
|
+
downloader.download_subtitles_only(['en'])
|
|
617
|
+
|
|
618
|
+
subtitle_files = Dir.glob(File.join(@test_output_dir, '*.srt'))
|
|
619
|
+
expect(subtitle_files).not_to be_empty
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
it 'downloads multiple language subtitles' do
|
|
623
|
+
downloader.download_subtitles_only(['en', 'es'])
|
|
624
|
+
|
|
625
|
+
files = Dir.glob(File.join(@test_output_dir, '*.*'))
|
|
626
|
+
expect(files.size).to be >= 2
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
it 'uses default subtitle languages from options' do
|
|
630
|
+
subtitle_options = YoutubeRb::Options.new(
|
|
631
|
+
output_path: @test_output_dir,
|
|
632
|
+
subtitle_langs: ['en', 'es']
|
|
633
|
+
)
|
|
634
|
+
dl = described_class.new(test_url, subtitle_options)
|
|
635
|
+
|
|
636
|
+
mock_extractor(video_data)
|
|
637
|
+
video_data['subtitles'].each do |lang, subs|
|
|
638
|
+
subs.each { |sub| stub_subtitle_download(sub['url']) }
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
dl.download_subtitles_only
|
|
642
|
+
|
|
643
|
+
files = Dir.glob(File.join(@test_output_dir, '*.*'))
|
|
644
|
+
expect(files.size).to be >= 2
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
it 'creates output directory' do
|
|
648
|
+
custom_dir = File.join(@test_output_dir, 'subs')
|
|
649
|
+
subtitle_options = YoutubeRb::Options.new(output_path: custom_dir)
|
|
650
|
+
dl = described_class.new(test_url, subtitle_options)
|
|
651
|
+
|
|
652
|
+
mock_extractor(video_data)
|
|
653
|
+
video_data['subtitles'].each do |lang, subs|
|
|
654
|
+
subs.each { |sub| stub_subtitle_download(sub['url']) }
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
dl.download_subtitles_only(['en'])
|
|
658
|
+
|
|
659
|
+
expect(Dir.exist?(custom_dir)).to be true
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
it 'handles missing subtitle language gracefully' do
|
|
663
|
+
expect {
|
|
664
|
+
downloader.download_subtitles_only(['nonexistent'])
|
|
665
|
+
}.not_to raise_error
|
|
666
|
+
|
|
667
|
+
subtitle_files = Dir.glob(File.join(@test_output_dir, '*.nonexistent.*'))
|
|
668
|
+
expect(subtitle_files).to be_empty
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
describe 'private methods' do
|
|
673
|
+
describe '#sanitize_filename' do
|
|
674
|
+
it 'removes invalid characters' do
|
|
675
|
+
dl = downloader
|
|
676
|
+
result = dl.send(:sanitize_filename, 'test/file:name*with?bad<chars>|')
|
|
677
|
+
|
|
678
|
+
expect(result).not_to include('/', ':', '*', '?', '<', '>', '|')
|
|
679
|
+
expect(result).to include('_')
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
it 'handles nil input' do
|
|
683
|
+
dl = downloader
|
|
684
|
+
result = dl.send(:sanitize_filename, nil)
|
|
685
|
+
|
|
686
|
+
expect(result).to eq('video')
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
it 'handles empty input' do
|
|
690
|
+
dl = downloader
|
|
691
|
+
result = dl.send(:sanitize_filename, '')
|
|
692
|
+
|
|
693
|
+
expect(result).to eq('video')
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
it 'trims whitespace' do
|
|
697
|
+
dl = downloader
|
|
698
|
+
result = dl.send(:sanitize_filename, ' test filename ')
|
|
699
|
+
|
|
700
|
+
expect(result).to eq('test filename')
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
describe '#audio_codec_for_format' do
|
|
705
|
+
it 'returns correct codec for mp3' do
|
|
706
|
+
dl = downloader
|
|
707
|
+
expect(dl.send(:audio_codec_for_format, 'mp3')).to eq('libmp3lame')
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
it 'returns correct codec for aac' do
|
|
711
|
+
dl = downloader
|
|
712
|
+
expect(dl.send(:audio_codec_for_format, 'aac')).to eq('aac')
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
it 'returns correct codec for opus' do
|
|
716
|
+
dl = downloader
|
|
717
|
+
expect(dl.send(:audio_codec_for_format, 'opus')).to eq('libopus')
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
it 'returns correct codec for flac' do
|
|
721
|
+
dl = downloader
|
|
722
|
+
expect(dl.send(:audio_codec_for_format, 'flac')).to eq('flac')
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
it 'returns copy for unknown format' do
|
|
726
|
+
dl = downloader
|
|
727
|
+
expect(dl.send(:audio_codec_for_format, 'unknown')).to eq('copy')
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
describe '#valid_segment_duration?' do
|
|
732
|
+
it 'returns true for 10 seconds (default minimum)' do
|
|
733
|
+
dl = downloader
|
|
734
|
+
expect(dl.send(:valid_segment_duration?, 10)).to be true
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
it 'returns true for 60 seconds (default maximum)' do
|
|
738
|
+
dl = downloader
|
|
739
|
+
expect(dl.send(:valid_segment_duration?, 60)).to be true
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
it 'returns true for duration in range' do
|
|
743
|
+
dl = downloader
|
|
744
|
+
expect(dl.send(:valid_segment_duration?, 35)).to be true
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
it 'returns false for less than 10 seconds (default minimum)' do
|
|
748
|
+
dl = downloader
|
|
749
|
+
expect(dl.send(:valid_segment_duration?, 9)).to be false
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
it 'returns false for more than 60 seconds (default maximum)' do
|
|
753
|
+
dl = downloader
|
|
754
|
+
expect(dl.send(:valid_segment_duration?, 61)).to be false
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
it 'uses custom min/max duration when configured' do
|
|
758
|
+
custom_options = YoutubeRb::Options.new(
|
|
759
|
+
output_path: @test_output_dir,
|
|
760
|
+
min_segment_duration: 5,
|
|
761
|
+
max_segment_duration: 120
|
|
762
|
+
)
|
|
763
|
+
dl = described_class.new(test_url, custom_options)
|
|
764
|
+
|
|
765
|
+
mock_extractor(video_data)
|
|
766
|
+
|
|
767
|
+
expect(dl.send(:valid_segment_duration?, 5)).to be true # custom minimum
|
|
768
|
+
expect(dl.send(:valid_segment_duration?, 120)).to be true # custom maximum
|
|
769
|
+
expect(dl.send(:valid_segment_duration?, 4)).to be false # below custom minimum
|
|
770
|
+
expect(dl.send(:valid_segment_duration?, 121)).to be false # above custom maximum
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
end
|
|
774
|
+
end
|