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,132 @@
1
+ module WebmockHelper
2
+ # Stub YouTube page request
3
+ def stub_youtube_page(video_id, video_data)
4
+ # Stub the main video page
5
+ url = "https://www.youtube.com/watch?v=#{video_id}"
6
+
7
+ # Also stub various YouTube URL formats
8
+ stub_youtube_url_variant("https://www.youtube.com/watch?v=#{video_id}", video_id, video_data)
9
+ stub_youtube_url_variant("https://youtu.be/#{video_id}", video_id, video_data)
10
+ stub_youtube_url_variant("https://www.youtube.com/embed/#{video_id}", video_id, video_data)
11
+ end
12
+
13
+ def stub_youtube_url_variant(url, video_id, video_data)
14
+ # Create a fake YouTube page HTML with player response
15
+ player_response = {
16
+ 'videoDetails' => {
17
+ 'videoId' => video_data['id'],
18
+ 'title' => video_data['title'],
19
+ 'shortDescription' => video_data['description'],
20
+ 'lengthSeconds' => video_data['duration'].to_s,
21
+ 'viewCount' => video_data['view_count'].to_s,
22
+ 'author' => video_data['uploader'],
23
+ 'channelId' => video_data['uploader_id']
24
+ },
25
+ 'streamingData' => {
26
+ 'formats' => video_data['formats'].map { |f| format_to_player_format(f) }
27
+ },
28
+ 'captions' => {
29
+ 'playerCaptionsTracklistRenderer' => {
30
+ 'captionTracks' => subtitles_to_caption_tracks(video_data['subtitles'])
31
+ }
32
+ },
33
+ 'microformat' => {
34
+ 'playerMicroformatRenderer' => {
35
+ 'title' => video_data['title'],
36
+ 'description' => video_data['description'],
37
+ 'uploadDate' => format_upload_date(video_data['upload_date']),
38
+ 'ownerChannelName' => video_data['uploader'],
39
+ 'externalChannelId' => video_data['uploader_id'],
40
+ 'thumbnail' => {
41
+ 'thumbnails' => [
42
+ { 'url' => video_data['thumbnail'] }
43
+ ]
44
+ }
45
+ }
46
+ }
47
+ }
48
+
49
+ html = <<~HTML
50
+ <!DOCTYPE html>
51
+ <html>
52
+ <head><title>#{video_data['title']}</title></head>
53
+ <body>
54
+ <script>
55
+ var ytInitialPlayerResponse = #{player_response.to_json};
56
+ </script>
57
+ </body>
58
+ </html>
59
+ HTML
60
+
61
+ stub_request(:get, url)
62
+ .to_return(status: 200, body: html, headers: { 'Content-Type' => 'text/html' })
63
+ end
64
+
65
+ # Stub video download
66
+ def stub_video_download(url, content = nil)
67
+ content ||= sample_video_binary
68
+
69
+ # Stub both with and without Range header
70
+ stub_request(:get, url)
71
+ .to_return(status: 200, body: content, headers: { 'Content-Type' => 'video/mp4' })
72
+
73
+ # Also stub all example.com video URLs
74
+ stub_request(:get, /example\.com\/video.*\.mp4/)
75
+ .to_return(status: 200, body: content, headers: { 'Content-Type' => 'video/mp4' })
76
+ end
77
+
78
+ # Stub subtitle download
79
+ def stub_subtitle_download(url, content = nil)
80
+ content ||= sample_subtitle_vtt
81
+
82
+ stub_request(:get, url)
83
+ .to_return(status: 200, body: content, headers: { 'Content-Type' => 'text/vtt' })
84
+ end
85
+
86
+ # Stub thumbnail download
87
+ def stub_thumbnail_download(url, content = nil)
88
+ content ||= "\xFF\xD8\xFF\xE0" # JPEG header
89
+
90
+ stub_request(:get, url)
91
+ .to_return(status: 200, body: content, headers: { 'Content-Type' => 'image/jpeg' })
92
+ end
93
+
94
+ private
95
+
96
+ def format_to_player_format(format)
97
+ {
98
+ 'itag' => format['format_id'].to_i,
99
+ 'url' => format['url'],
100
+ 'mimeType' => "video/#{format['ext']}; codecs=\"#{format['vcodec']}, #{format['acodec']}\"",
101
+ 'width' => format['width'],
102
+ 'height' => format['height'],
103
+ 'quality' => format['quality'],
104
+ 'qualityLabel' => "#{format['height']}p",
105
+ 'bitrate' => format['tbr'] ? format['tbr'] * 1000 : nil
106
+ }
107
+ end
108
+
109
+ def subtitles_to_caption_tracks(subtitles)
110
+ return [] unless subtitles
111
+
112
+ subtitles.flat_map do |lang, subs|
113
+ subs.map do |sub|
114
+ {
115
+ 'languageCode' => lang,
116
+ 'baseUrl' => sub['url'],
117
+ 'name' => { 'simpleText' => sub['name'] }
118
+ }
119
+ end
120
+ end
121
+ end
122
+
123
+ def format_upload_date(date_str)
124
+ return nil unless date_str
125
+ # Convert YYYYMMDD to YYYY-MM-DD
126
+ "#{date_str[0..3]}-#{date_str[4..5]}-#{date_str[6..7]}"
127
+ end
128
+ end
129
+
130
+ RSpec.configure do |config|
131
+ config.include WebmockHelper
132
+ end
@@ -0,0 +1,200 @@
1
+ RSpec.describe YoutubeRb do
2
+ it "has a version number" do
3
+ expect(YoutubeRb::VERSION).not_to be nil
4
+ end
5
+
6
+ describe ".new" do
7
+ it "creates a client instance" do
8
+ client = YoutubeRb.new
9
+ expect(client).to be_a(YoutubeRb::Client)
10
+ end
11
+
12
+ it "accepts options" do
13
+ client = YoutubeRb.new(output_path: './test')
14
+ expect(client.options.output_path).to eq('./test')
15
+ end
16
+ end
17
+
18
+ describe YoutubeRb::Client do
19
+ let(:client) { YoutubeRb::Client.new(output_path: './test_downloads') }
20
+
21
+ describe "#initialize" do
22
+ it "creates options from hash" do
23
+ expect(client.options).to be_a(YoutubeRb::Options)
24
+ expect(client.options.output_path).to eq('./test_downloads')
25
+ end
26
+ end
27
+
28
+ describe "#check_dependencies" do
29
+ it "returns hash with dependency status" do
30
+ deps = client.check_dependencies
31
+ expect(deps).to be_a(Hash)
32
+ expect(deps).to have_key(:ffmpeg)
33
+ expect(deps).to have_key(:ytdlp)
34
+ expect(deps).to have_key(:ytdlp_version)
35
+ end
36
+
37
+ it "checks ffmpeg availability" do
38
+ deps = client.check_dependencies
39
+ expect([true, false]).to include(deps[:ffmpeg])
40
+ end
41
+
42
+ it "checks yt-dlp availability" do
43
+ deps = client.check_dependencies
44
+ expect([true, false]).to include(deps[:ytdlp])
45
+ end
46
+ end
47
+
48
+ describe "#version" do
49
+ it "returns version string" do
50
+ expect(client.version).to eq(YoutubeRb::VERSION)
51
+ end
52
+ end
53
+ end
54
+
55
+ describe YoutubeRb::Options do
56
+ describe "#initialize" do
57
+ it "creates with default options" do
58
+ options = YoutubeRb::Options.new
59
+ expect(options.format).to eq('best')
60
+ expect(options.quality).to eq('best')
61
+ expect(options.output_path).to eq('./downloads')
62
+ expect(options.use_ytdlp).to eq(false)
63
+ expect(options.ytdlp_fallback).to eq(true)
64
+ expect(options.verbose).to eq(false)
65
+ end
66
+
67
+ it "creates with custom options" do
68
+ options = YoutubeRb::Options.new(
69
+ format: '1080p',
70
+ output_path: '/tmp',
71
+ write_subtitles: true,
72
+ use_ytdlp: true,
73
+ verbose: true
74
+ )
75
+ expect(options.format).to eq('1080p')
76
+ expect(options.output_path).to eq('/tmp')
77
+ expect(options.write_subtitles).to eq(true)
78
+ expect(options.use_ytdlp).to eq(true)
79
+ expect(options.verbose).to eq(true)
80
+ end
81
+ end
82
+
83
+ describe "#to_h" do
84
+ it "returns hash representation" do
85
+ options = YoutubeRb::Options.new(format: 'best', use_ytdlp: true)
86
+ hash = options.to_h
87
+ expect(hash).to be_a(Hash)
88
+ expect(hash[:format]).to eq('best')
89
+ expect(hash[:use_ytdlp]).to eq(true)
90
+ expect(hash).to have_key(:ytdlp_fallback)
91
+ expect(hash).to have_key(:verbose)
92
+ end
93
+ end
94
+
95
+ describe "#merge" do
96
+ it "merges options" do
97
+ options = YoutubeRb::Options.new(format: 'best')
98
+ options.merge(format: '720p', quality: 'high')
99
+ expect(options.format).to eq('720p')
100
+ expect(options.quality).to eq('high')
101
+ end
102
+ end
103
+ end
104
+
105
+ describe YoutubeRb::VideoInfo do
106
+ let(:video_data) do
107
+ {
108
+ 'id' => 'test123',
109
+ 'title' => 'Test Video',
110
+ 'description' => 'Test description',
111
+ 'duration' => 180,
112
+ 'view_count' => 1000,
113
+ 'formats' => [
114
+ {
115
+ 'format_id' => '18',
116
+ 'ext' => 'mp4',
117
+ 'height' => 360,
118
+ 'vcodec' => 'avc1',
119
+ 'acodec' => 'mp4a'
120
+ }
121
+ ],
122
+ 'subtitles' => {
123
+ 'en' => [{ 'ext' => 'srt', 'url' => 'https://example.com/sub.srt' }]
124
+ }
125
+ }
126
+ end
127
+
128
+ let(:video_info) { YoutubeRb::VideoInfo.new(video_data) }
129
+
130
+ describe "#initialize" do
131
+ it "parses video data" do
132
+ expect(video_info.id).to eq('test123')
133
+ expect(video_info.title).to eq('Test Video')
134
+ expect(video_info.duration).to eq(180)
135
+ expect(video_info.view_count).to eq(1000)
136
+ end
137
+ end
138
+
139
+ describe "#duration_formatted" do
140
+ it "formats duration" do
141
+ expect(video_info.duration_formatted).to eq('03:00')
142
+ end
143
+
144
+ it "formats duration with hours" do
145
+ info = YoutubeRb::VideoInfo.new('duration' => 3665)
146
+ expect(info.duration_formatted).to eq('01:01:05')
147
+ end
148
+ end
149
+
150
+ describe "#available_formats" do
151
+ it "returns format IDs" do
152
+ expect(video_info.available_formats).to eq(['18'])
153
+ end
154
+ end
155
+
156
+ describe "#available_subtitle_languages" do
157
+ it "returns subtitle languages" do
158
+ expect(video_info.available_subtitle_languages).to eq(['en'])
159
+ end
160
+ end
161
+
162
+ describe "#to_h" do
163
+ it "returns hash representation" do
164
+ hash = video_info.to_h
165
+ expect(hash).to be_a(Hash)
166
+ expect(hash[:id]).to eq('test123')
167
+ expect(hash[:title]).to eq('Test Video')
168
+ end
169
+ end
170
+ end
171
+
172
+ describe YoutubeRb::Extractor do
173
+ let(:extractor) { YoutubeRb::Extractor.new('https://www.youtube.com/watch?v=test') }
174
+
175
+ describe "#initialize" do
176
+ it "creates extractor with URL" do
177
+ expect(extractor.url).to eq('https://www.youtube.com/watch?v=test')
178
+ end
179
+ end
180
+ end
181
+
182
+ describe YoutubeRb::Downloader do
183
+ let(:url) { 'https://www.youtube.com/watch?v=test' }
184
+ let(:options) { YoutubeRb::Options.new(output_path: './test_downloads') }
185
+ let(:downloader) { YoutubeRb::Downloader.new(url, options) }
186
+
187
+ describe "#initialize" do
188
+ it "creates downloader with URL and options" do
189
+ expect(downloader.url).to eq(url)
190
+ expect(downloader.options).to be_a(YoutubeRb::Options)
191
+ end
192
+
193
+ it "accepts hash options" do
194
+ dl = YoutubeRb::Downloader.new(url, { output_path: './test' })
195
+ expect(dl.options).to be_a(YoutubeRb::Options)
196
+ expect(dl.options.output_path).to eq('./test')
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,178 @@
1
+ RSpec.describe YoutubeRb::YtdlpWrapper do
2
+ describe ".available?" do
3
+ it "returns boolean" do
4
+ result = described_class.available?
5
+ expect([true, false]).to include(result)
6
+ end
7
+ end
8
+
9
+ describe ".version" do
10
+ it "returns version string" do
11
+ version = described_class.version
12
+ expect(version).to be_a(String)
13
+ expect(version).not_to be_empty
14
+ end
15
+
16
+ it "returns 'not installed' when yt-dlp command fails" do
17
+ allow(Open3).to receive(:capture2).with('yt-dlp', '--version').and_raise(Errno::ENOENT)
18
+ version = described_class.version
19
+ expect(version).to eq('not installed')
20
+ end
21
+ end
22
+
23
+ context "when yt-dlp is available", if: YoutubeRb::YtdlpWrapper.available? do
24
+ let(:options) { YoutubeRb::Options.new }
25
+ let(:wrapper) { described_class.new(options) }
26
+
27
+ describe "#initialize" do
28
+ it "creates wrapper with options" do
29
+ expect(wrapper.options).to be_a(YoutubeRb::Options)
30
+ end
31
+
32
+ it "accepts hash options" do
33
+ wrapper = described_class.new(output_path: './test')
34
+ expect(wrapper.options).to be_a(YoutubeRb::Options)
35
+ end
36
+ end
37
+
38
+ describe "#extract_info" do
39
+ it "extracts video information", :slow do
40
+ # Note: This test requires internet and may need cookies for some videos
41
+ # Skip if not running integration tests
42
+ skip "Requires internet and may need cookies" unless ENV['RUN_INTEGRATION_TESTS']
43
+
44
+ url = 'https://www.youtube.com/watch?v=jNQXAC9IVRw'
45
+ info = wrapper.extract_info(url)
46
+
47
+ expect(info).to be_a(Hash)
48
+ expect(info).to have_key('id')
49
+ expect(info).to have_key('title')
50
+ expect(info).to have_key('duration')
51
+ expect(info['id']).to eq('jNQXAC9IVRw')
52
+ end
53
+
54
+ it "raises error for invalid URL" do
55
+ expect {
56
+ wrapper.extract_info('https://www.youtube.com/watch?v=invalidvideoid')
57
+ }.to raise_error(YoutubeRb::YtdlpWrapper::YtdlpError)
58
+ end
59
+ end
60
+
61
+ describe "private methods" do
62
+ describe "#build_info_args" do
63
+ it "builds correct arguments for info extraction" do
64
+ url = 'https://www.youtube.com/watch?v=test'
65
+ args = wrapper.send(:build_info_args, url)
66
+
67
+ expect(args).to include('yt-dlp')
68
+ expect(args).to include('--dump-json')
69
+ expect(args).to include('--no-playlist')
70
+ expect(args).to include(url)
71
+ end
72
+
73
+ it "includes cookies file if specified" do
74
+ options = YoutubeRb::Options.new(cookies_file: './test_cookies.txt')
75
+ wrapper = described_class.new(options)
76
+
77
+ # Create temporary cookies file
78
+ File.write('./test_cookies.txt', 'test')
79
+
80
+ url = 'https://www.youtube.com/watch?v=test'
81
+ args = wrapper.send(:build_info_args, url)
82
+
83
+ expect(args).to include('--cookies')
84
+ expect(args).to include('./test_cookies.txt')
85
+
86
+ File.delete('./test_cookies.txt')
87
+ end
88
+ end
89
+
90
+ describe "#build_download_args" do
91
+ it "builds correct arguments for download" do
92
+ url = 'https://www.youtube.com/watch?v=test'
93
+ args = wrapper.send(:build_download_args, url, nil)
94
+
95
+ expect(args).to include('yt-dlp')
96
+ expect(args).to include(url)
97
+ end
98
+
99
+ it "includes output path" do
100
+ url = 'https://www.youtube.com/watch?v=test'
101
+ output = './test_output.mp4'
102
+ args = wrapper.send(:build_download_args, url, output)
103
+
104
+ expect(args).to include('-o')
105
+ expect(args).to include(output)
106
+ end
107
+
108
+ it "includes audio extraction options" do
109
+ options = YoutubeRb::Options.new(
110
+ extract_audio: true,
111
+ audio_format: 'mp3',
112
+ audio_quality: '192'
113
+ )
114
+ wrapper = described_class.new(options)
115
+
116
+ url = 'https://www.youtube.com/watch?v=test'
117
+ args = wrapper.send(:build_download_args, url, nil)
118
+
119
+ expect(args).to include('-x')
120
+ expect(args).to include('--audio-format')
121
+ expect(args).to include('mp3')
122
+ expect(args).to include('--audio-quality')
123
+ expect(args).to include('192')
124
+ end
125
+
126
+ it "includes subtitle options" do
127
+ options = YoutubeRb::Options.new(
128
+ write_subtitles: true,
129
+ subtitle_langs: ['en', 'ru']
130
+ )
131
+ wrapper = described_class.new(options)
132
+
133
+ url = 'https://www.youtube.com/watch?v=test'
134
+ args = wrapper.send(:build_download_args, url, nil)
135
+
136
+ expect(args).to include('--write-subs')
137
+ expect(args).to include('--sub-langs')
138
+ expect(args).to include('en,ru')
139
+ end
140
+ end
141
+
142
+ describe "#build_format_string" do
143
+ it "returns best format by default" do
144
+ format = wrapper.send(:build_format_string)
145
+ expect(format).to eq('bestvideo+bestaudio/best')
146
+ end
147
+
148
+ it "handles quality specifications" do
149
+ options = YoutubeRb::Options.new(quality: '720p')
150
+ wrapper = described_class.new(options)
151
+
152
+ format = wrapper.send(:build_format_string)
153
+ expect(format).to include('720')
154
+ end
155
+
156
+ it "returns worst format when requested" do
157
+ options = YoutubeRb::Options.new(quality: 'worst')
158
+ wrapper = described_class.new(options)
159
+
160
+ format = wrapper.send(:build_format_string)
161
+ expect(format).to eq('worstvideo+worstaudio/worst')
162
+ end
163
+ end
164
+ end
165
+ end
166
+
167
+ context "when yt-dlp is not available", unless: YoutubeRb::YtdlpWrapper.available? do
168
+ describe "#initialize" do
169
+ it "raises YtdlpNotFoundError" do
170
+ options = YoutubeRb::Options.new
171
+
172
+ expect {
173
+ described_class.new(options)
174
+ }.to raise_error(YoutubeRb::YtdlpWrapper::YtdlpNotFoundError, /yt-dlp is not installed/)
175
+ end
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,39 @@
1
+ require_relative "lib/youtube-rb/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "youtube-rb"
5
+ spec.version = YoutubeRb::VERSION
6
+ spec.authors = ["Maxim Veysgeym"]
7
+ spec.email = ["qew7777@gmail.com"]
8
+
9
+ spec.summary = "A Ruby library for downloading and extracting YouTube videos and subtitles"
10
+ spec.description = "A Ruby library inspired by youtube-dl for downloading videos, extracting video segments, and fetching subtitles from YouTube and other video platforms"
11
+ spec.homepage = "https://github.com/Qew7/youtube-rb"
12
+ spec.license = "MIT"
13
+ spec.required_ruby_version = ">= 3.4.0"
14
+
15
+ spec.metadata["homepage_uri"] = spec.homepage
16
+ spec.metadata["source_code_uri"] = "https://github.com/Qew7/youtube-rb"
17
+ spec.metadata["changelog_uri"] = "https://github.com/Qew7/youtube-rb/blob/master/CHANGELOG.md"
18
+
19
+ # Specify which files should be added to the gem when it is released.
20
+ spec.files = Dir.glob("{lib,bin,spec}/**/*") + %w[README.md LICENSE Rakefile youtube-rb.gemspec]
21
+ spec.bindir = "bin"
22
+ spec.executables = spec.files.grep(%r{\Abin/}) { |f| File.basename(f) }
23
+ spec.require_paths = ["lib"]
24
+
25
+ # Runtime dependencies
26
+ spec.add_dependency "faraday", "~> 2.14"
27
+ spec.add_dependency "faraday-retry", "~> 2.4"
28
+ spec.add_dependency "nokogiri", "~> 1.19"
29
+ spec.add_dependency "streamio-ffmpeg", "~> 3.0"
30
+ spec.add_dependency "addressable", "~> 2.8"
31
+ spec.add_dependency "base64", "~> 0.2"
32
+
33
+ # Development dependencies
34
+ spec.add_development_dependency "bundler", ">= 2.7"
35
+ spec.add_development_dependency "rake", "~> 13.0"
36
+ spec.add_development_dependency "rspec", "~> 3.13"
37
+ spec.add_development_dependency "webmock", "~> 3.26"
38
+ spec.add_development_dependency "vcr", "~> 6.4"
39
+ end