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.
@@ -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