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,514 @@
1
+ RSpec.describe YoutubeRb::Client do
2
+ let(:test_url) { 'https://www.youtube.com/watch?v=test123abc' }
3
+ let(:video_data) { sample_video_data }
4
+ let(:client) { described_class.new(output_path: @test_output_dir) }
5
+
6
+ before do
7
+ mock_extractor(video_data)
8
+ end
9
+
10
+ describe '#initialize' do
11
+ it 'creates client with default options' do
12
+ client = described_class.new
13
+ expect(client).to be_a(described_class)
14
+ expect(client.options).to be_a(YoutubeRb::Options)
15
+ end
16
+
17
+ it 'creates client with custom options' do
18
+ client = described_class.new(output_path: '/tmp/test', format: '720p')
19
+ expect(client.options.output_path).to eq('/tmp/test')
20
+ expect(client.options.format).to eq('720p')
21
+ end
22
+
23
+ it 'accepts multiple options' do
24
+ client = described_class.new(
25
+ output_path: './custom',
26
+ write_subtitles: true,
27
+ subtitle_langs: ['en', 'es'],
28
+ audio_format: 'aac'
29
+ )
30
+ expect(client.options.output_path).to eq('./custom')
31
+ expect(client.options.write_subtitles).to eq(true)
32
+ expect(client.options.subtitle_langs).to eq(['en', 'es'])
33
+ expect(client.options.audio_format).to eq('aac')
34
+ end
35
+ end
36
+
37
+ describe '#info' do
38
+ it 'returns video information' do
39
+ info = client.info(test_url)
40
+
41
+ expect(info).to be_a(YoutubeRb::VideoInfo)
42
+ expect(info.id).to eq('test123abc')
43
+ expect(info.title).to eq('Test Video Title')
44
+ expect(info.uploader).to eq('Test Channel')
45
+ expect(info.duration).to eq(180)
46
+ expect(info.view_count).to eq(1000)
47
+ end
48
+
49
+ it 'returns formats' do
50
+ info = client.info(test_url)
51
+
52
+ expect(info.formats).to be_an(Array)
53
+ expect(info.formats.size).to eq(2)
54
+ expect(info.formats.first[:format_id]).to eq('18')
55
+ end
56
+
57
+ it 'returns subtitles' do
58
+ info = client.info(test_url)
59
+
60
+ expect(info.subtitles).to be_a(Hash)
61
+ expect(info.subtitles.keys).to include('en', 'es')
62
+ end
63
+
64
+ it 'raises error for invalid URL' do
65
+ mock_extractor_error
66
+
67
+ expect {
68
+ client.info('https://www.youtube.com/watch?v=invalid')
69
+ }.to raise_error(YoutubeRb::Extractor::ExtractionError)
70
+ end
71
+ end
72
+
73
+ describe '#download' do
74
+ let(:video_url) { video_data['formats'].last['url'] } # Use 720p (highest quality)
75
+
76
+ before do
77
+ stub_video_download(video_url)
78
+ end
79
+
80
+ it 'downloads video file' do
81
+ output_file = client.download(test_url)
82
+
83
+ expect(output_file).to be_a(String)
84
+ expect(File.exist?(output_file)).to be true
85
+ expect(File.size(output_file)).to be > 0
86
+ end
87
+
88
+ it 'accepts additional options' do
89
+ output_file = client.download(test_url, output_template: 'custom-%(id)s.%(ext)s')
90
+
91
+ expect(output_file).to include('custom-test123abc')
92
+ expect(File.exist?(output_file)).to be true
93
+ end
94
+
95
+ it 'creates output directory if needed' do
96
+ custom_dir = File.join(@test_output_dir, 'nested', 'path')
97
+ custom_client = described_class.new(output_path: custom_dir)
98
+
99
+ mock_extractor(video_data)
100
+ stub_video_download(video_url)
101
+
102
+ output_file = custom_client.download(test_url)
103
+
104
+ expect(Dir.exist?(custom_dir)).to be true
105
+ expect(File.exist?(output_file)).to be true
106
+ end
107
+ end
108
+
109
+ describe '#download_segment' do
110
+ before do
111
+ allow(YoutubeRb::YtdlpWrapper).to receive(:available?).and_return(true)
112
+ allow_any_instance_of(YoutubeRb::YtdlpWrapper).to receive(:download_segment) do |_, url, start_time, end_time, output_file|
113
+ output_file || File.join(@test_output_dir, "segment-#{start_time}-#{end_time}.mp4")
114
+ end
115
+ end
116
+
117
+ it 'downloads video segment using yt-dlp' do
118
+ output_file = client.download_segment(test_url, 10, 30)
119
+
120
+ expect(output_file).to be_a(String)
121
+ expect(output_file).to include('segment')
122
+ end
123
+
124
+ it 'accepts custom output file' do
125
+ custom_file = File.join(@test_output_dir, 'my-segment.mp4')
126
+ output_file = client.download_segment(test_url, 10, 30, output_file: custom_file)
127
+
128
+ expect(output_file).to eq(custom_file)
129
+ end
130
+
131
+ it 'validates segment duration (minimum 10 seconds)' do
132
+ expect {
133
+ client.download_segment(test_url, 0, 5)
134
+ }.to raise_error(ArgumentError, /between 10 and 60 seconds/)
135
+ end
136
+
137
+ it 'validates segment duration (maximum 60 seconds)' do
138
+ expect {
139
+ client.download_segment(test_url, 0, 70)
140
+ }.to raise_error(ArgumentError, /between 10 and 60 seconds/)
141
+ end
142
+
143
+ it 'validates start time less than end time' do
144
+ expect {
145
+ client.download_segment(test_url, 30, 10)
146
+ }.to raise_error(ArgumentError, /Start time must be less than end time/)
147
+ end
148
+
149
+ it 'raises error if yt-dlp not available' do
150
+ allow(YoutubeRb::YtdlpWrapper).to receive(:available?).and_return(false)
151
+
152
+ expect {
153
+ client.download_segment(test_url, 10, 30)
154
+ }.to raise_error(YoutubeRb::Downloader::DownloadError, /yt-dlp is required/)
155
+ end
156
+ end
157
+
158
+ describe '#download_segments' do
159
+ before do
160
+ allow(YoutubeRb::YtdlpWrapper).to receive(:available?).and_return(true)
161
+ allow_any_instance_of(YoutubeRb::Downloader).to receive(:ffmpeg_available?).and_return(true)
162
+ allow(Open3).to receive(:capture3).and_return(['', '', double(success?: true)])
163
+ allow_any_instance_of(YoutubeRb::YtdlpWrapper).to receive(:download) do |_, url, output_path|
164
+ FileUtils.touch(output_path)
165
+ output_path
166
+ end
167
+ end
168
+
169
+ it 'downloads multiple video segments using yt-dlp' do
170
+ segments = [
171
+ { start: 10, end: 30 },
172
+ { start: 60, end: 90 },
173
+ { start: 120, end: 150 }
174
+ ]
175
+
176
+ output_files = client.download_segments(test_url, segments)
177
+
178
+ expect(output_files).to be_an(Array)
179
+ expect(output_files.size).to eq(3)
180
+ output_files.each do |file|
181
+ expect(file).to be_a(String)
182
+ end
183
+ end
184
+
185
+ it 'accepts custom output files for each segment' do
186
+ segments = [
187
+ { start: 10, end: 30, output_file: File.join(@test_output_dir, 'seg1.mp4') },
188
+ { start: 60, end: 90, output_file: File.join(@test_output_dir, 'seg2.mp4') }
189
+ ]
190
+
191
+ output_files = client.download_segments(test_url, segments)
192
+
193
+ expect(output_files[0]).to end_with('seg1.mp4')
194
+ expect(output_files[1]).to end_with('seg2.mp4')
195
+ end
196
+
197
+ it 'enables caching by default for batch downloads' do
198
+ segments = [
199
+ { start: 10, end: 30 },
200
+ { start: 60, end: 90 }
201
+ ]
202
+
203
+ # Verify that cache_full_video is enabled by default for batch downloads
204
+ expect_any_instance_of(YoutubeRb::Downloader)
205
+ .to receive(:download_segments)
206
+ .and_call_original
207
+
208
+ client.download_segments(test_url, segments)
209
+ end
210
+
211
+ it 'accepts additional options' do
212
+ segments = [
213
+ { start: 10, end: 30 }
214
+ ]
215
+
216
+ output_files = client.download_segments(test_url, segments, cache_full_video: false)
217
+
218
+ expect(output_files).to be_an(Array)
219
+ expect(output_files.size).to eq(1)
220
+ end
221
+
222
+ it 'validates segments array' do
223
+ expect {
224
+ client.download_segments(test_url, "not an array")
225
+ }.to raise_error(ArgumentError, /segments must be an Array/)
226
+ end
227
+
228
+ it 'validates segment durations' do
229
+ segments = [
230
+ { start: 10, end: 30 }, # valid
231
+ { start: 60, end: 65 } # invalid (5 seconds)
232
+ ]
233
+
234
+ expect {
235
+ client.download_segments(test_url, segments)
236
+ }.to raise_error(ArgumentError, /between 10 and 60 seconds/)
237
+ end
238
+
239
+ it 'raises error if yt-dlp not available' do
240
+ allow(YoutubeRb::YtdlpWrapper).to receive(:available?).and_return(false)
241
+
242
+ segments = [{ start: 10, end: 30 }]
243
+
244
+ expect {
245
+ client.download_segments(test_url, segments)
246
+ }.to raise_error(YoutubeRb::Downloader::DownloadError, /yt-dlp is required/)
247
+ end
248
+ end
249
+
250
+ describe '#download_subtitles' do
251
+ before do
252
+ video_data['subtitles'].each do |lang, subs|
253
+ subs.each { |sub| stub_subtitle_download(sub['url']) }
254
+ end
255
+ end
256
+
257
+ it 'downloads subtitles for default languages' do
258
+ client.download_subtitles(test_url)
259
+
260
+ # Should download at least one subtitle file
261
+ subtitle_files = Dir.glob(File.join(@test_output_dir, '*.srt'))
262
+ expect(subtitle_files).not_to be_empty
263
+ end
264
+
265
+ it 'downloads subtitles for specific languages' do
266
+ client.download_subtitles(test_url, langs: ['en'])
267
+
268
+ subtitle_files = Dir.glob(File.join(@test_output_dir, '*.en.*'))
269
+ expect(subtitle_files).not_to be_empty
270
+ end
271
+
272
+ it 'accepts additional options' do
273
+ custom_client = described_class.new(
274
+ output_path: @test_output_dir,
275
+ subtitle_format: 'vtt'
276
+ )
277
+
278
+ mock_extractor(video_data)
279
+ video_data['subtitles'].each do |lang, subs|
280
+ subs.each { |sub| stub_subtitle_download(sub['url']) }
281
+ end
282
+
283
+ custom_client.download_subtitles(test_url, langs: ['en', 'es'])
284
+
285
+ # Files should exist
286
+ files = Dir.glob(File.join(@test_output_dir, '*.*'))
287
+ expect(files).not_to be_empty
288
+ end
289
+ end
290
+
291
+ describe '#download_with_metadata' do
292
+ let(:video_url) { video_data['formats'].first['url'] }
293
+
294
+ before do
295
+ stub_video_download(video_url)
296
+ stub_thumbnail_download(video_data['thumbnail'])
297
+ video_data['subtitles'].each do |lang, subs|
298
+ subs.each { |sub| stub_subtitle_download(sub['url']) }
299
+ end
300
+ end
301
+
302
+ it 'downloads video with metadata files' do
303
+ output_file = client.download_with_metadata(test_url)
304
+
305
+ expect(File.exist?(output_file)).to be true
306
+
307
+ # Check for metadata files
308
+ base_name = File.basename(output_file, '.*')
309
+ dir = File.dirname(output_file)
310
+
311
+ # Should create info.json
312
+ json_file = Dir.glob(File.join(dir, '*.info.json')).first
313
+ expect(json_file).not_to be_nil
314
+
315
+ # Should create thumbnail
316
+ thumbnail_files = Dir.glob(File.join(dir, '*{.jpg,.png,.webp}'))
317
+ expect(thumbnail_files).not_to be_empty
318
+ end
319
+
320
+ it 'downloads subtitles along with video' do
321
+ output_file = client.download_with_metadata(test_url)
322
+
323
+ # Should create subtitle files
324
+ subtitle_files = Dir.glob(File.join(@test_output_dir, '*.{srt,vtt}'))
325
+ expect(subtitle_files).not_to be_empty
326
+ end
327
+ end
328
+
329
+ describe '#extract_audio' do
330
+ let(:video_url) { video_data['formats'].first['url'] }
331
+
332
+ before do
333
+ stub_video_download(video_url)
334
+
335
+ # Mock ffmpeg for audio extraction
336
+ allow_any_instance_of(YoutubeRb::Downloader).to receive(:ffmpeg_available?).and_return(true)
337
+ allow(Open3).to receive(:capture3).and_return(['', '', double(success?: true)])
338
+ end
339
+
340
+ it 'extracts audio in default format (mp3)' do
341
+ output_file = client.extract_audio(test_url)
342
+
343
+ expect(output_file).to be_a(String)
344
+ end
345
+
346
+ it 'extracts audio in specified format' do
347
+ output_file = client.extract_audio(test_url, format: 'aac')
348
+
349
+ expect(output_file).to be_a(String)
350
+ end
351
+
352
+ it 'extracts audio with specified quality' do
353
+ output_file = client.extract_audio(test_url, format: 'mp3', quality: '320')
354
+
355
+ expect(output_file).to be_a(String)
356
+ end
357
+
358
+ it 'accepts additional options' do
359
+ output_file = client.extract_audio(
360
+ test_url,
361
+ format: 'opus',
362
+ quality: '128',
363
+ output_template: 'audio-%(id)s.%(ext)s'
364
+ )
365
+
366
+ expect(output_file).to be_a(String)
367
+ end
368
+
369
+ it 'raises error if ffmpeg not available' do
370
+ allow_any_instance_of(YoutubeRb::Downloader).to receive(:ffmpeg_available?).and_return(false)
371
+
372
+ expect {
373
+ client.extract_audio(test_url)
374
+ }.to raise_error(YoutubeRb::Downloader::DownloadError, /FFmpeg is required/)
375
+ end
376
+ end
377
+
378
+ describe '#valid_url?' do
379
+ it 'returns true for valid YouTube URL' do
380
+ expect(client.valid_url?(test_url)).to be true
381
+ end
382
+
383
+ it 'returns false for invalid URL' do
384
+ mock_extractor_error
385
+
386
+ expect(client.valid_url?('https://www.youtube.com/watch?v=invalid')).to be false
387
+ end
388
+
389
+ it 'returns false for nil URL' do
390
+ expect(client.valid_url?(nil)).to be false
391
+ end
392
+
393
+ it 'returns false for empty URL' do
394
+ expect(client.valid_url?('')).to be false
395
+ end
396
+
397
+ it 'returns false for non-YouTube URL' do
398
+ mock_extractor_error(YoutubeRb::Extractor::ExtractionError, 'Not a YouTube URL')
399
+
400
+ expect(client.valid_url?('https://example.com')).to be false
401
+ end
402
+ end
403
+
404
+ describe '#formats' do
405
+ it 'returns available formats' do
406
+ formats = client.formats(test_url)
407
+
408
+ expect(formats).to be_an(Array)
409
+ expect(formats.size).to eq(2)
410
+ expect(formats.first).to be_a(Hash)
411
+ expect(formats.first[:format_id]).to eq('18')
412
+ expect(formats.first[:height]).to eq(360)
413
+ end
414
+
415
+ it 'returns empty array if no formats available' do
416
+ no_formats_data = video_data.dup
417
+ no_formats_data['formats'] = []
418
+
419
+ mock_extractor(no_formats_data)
420
+
421
+ formats = client.formats('https://www.youtube.com/watch?v=noformats')
422
+ expect(formats).to eq([])
423
+ end
424
+ end
425
+
426
+ describe '#subtitles' do
427
+ it 'returns available subtitles' do
428
+ subtitles = client.subtitles(test_url)
429
+
430
+ expect(subtitles).to be_a(Hash)
431
+ expect(subtitles.keys).to include('en', 'es')
432
+ expect(subtitles['en']).to be_an(Array)
433
+ expect(subtitles['en'].first).to have_key(:url)
434
+ end
435
+
436
+ it 'returns empty hash if no subtitles available' do
437
+ no_subs_data = video_data.dup
438
+ no_subs_data['subtitles'] = {}
439
+
440
+ mock_extractor(no_subs_data)
441
+
442
+ subtitles = client.subtitles('https://www.youtube.com/watch?v=nosubs')
443
+ expect(subtitles).to eq({})
444
+ end
445
+ end
446
+
447
+ describe '#configure' do
448
+ it 'updates client options' do
449
+ client.configure(format: '1080p', quality: 'high')
450
+
451
+ expect(client.options.format).to eq('1080p')
452
+ expect(client.options.quality).to eq('high')
453
+ end
454
+
455
+ it 'returns self for chaining' do
456
+ result = client.configure(format: '720p')
457
+
458
+ expect(result).to eq(client)
459
+ end
460
+
461
+ it 'preserves existing options' do
462
+ original_path = client.options.output_path
463
+ client.configure(format: '720p')
464
+
465
+ expect(client.options.output_path).to eq(original_path)
466
+ end
467
+
468
+ it 'can be chained' do
469
+ client
470
+ .configure(format: '1080p')
471
+ .configure(write_subtitles: true)
472
+ .configure(audio_quality: '320')
473
+
474
+ expect(client.options.format).to eq('1080p')
475
+ expect(client.options.write_subtitles).to eq(true)
476
+ expect(client.options.audio_quality).to eq('320')
477
+ end
478
+ end
479
+
480
+ describe '#check_dependencies' do
481
+ it 'returns hash with dependency status' do
482
+ deps = client.check_dependencies
483
+
484
+ expect(deps).to be_a(Hash)
485
+ expect(deps).to have_key(:ffmpeg)
486
+ expect([true, false]).to include(deps[:ffmpeg])
487
+ end
488
+
489
+ it 'checks ffmpeg availability' do
490
+ allow(client).to receive(:system).with('which ffmpeg > /dev/null 2>&1').and_return(true)
491
+
492
+ deps = client.check_dependencies
493
+ expect(deps[:ffmpeg]).to be true
494
+ end
495
+
496
+ it 'returns false when ffmpeg not available' do
497
+ allow(client).to receive(:system).with('which ffmpeg > /dev/null 2>&1').and_return(false)
498
+
499
+ deps = client.check_dependencies
500
+ expect(deps[:ffmpeg]).to be false
501
+ end
502
+ end
503
+
504
+ describe '#version' do
505
+ it 'returns version string' do
506
+ expect(client.version).to eq(YoutubeRb::VERSION)
507
+ end
508
+
509
+ it 'returns non-empty version' do
510
+ expect(client.version).not_to be_nil
511
+ expect(client.version).not_to be_empty
512
+ end
513
+ end
514
+ end