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,179 @@
1
+ module YoutubeRb
2
+ class VideoInfo
3
+ attr_reader :id, :title, :description, :uploader, :uploader_id,
4
+ :duration, :view_count, :like_count, :upload_date,
5
+ :thumbnail, :formats, :subtitles, :url, :ext,
6
+ :webpage_url, :fulltitle
7
+
8
+ def initialize(data)
9
+ @id = data['id'] || data[:id]
10
+ @title = data['title'] || data[:title]
11
+ @fulltitle = data['fulltitle'] || data[:fulltitle] || @title
12
+ @description = data['description'] || data[:description]
13
+ @uploader = data['uploader'] || data[:uploader]
14
+ @uploader_id = data['uploader_id'] || data[:uploader_id]
15
+ @duration = parse_duration(data['duration'] || data[:duration])
16
+ @view_count = data['view_count'] || data[:view_count]
17
+ @like_count = data['like_count'] || data[:like_count]
18
+ @upload_date = data['upload_date'] || data[:upload_date]
19
+ @thumbnail = data['thumbnail'] || data[:thumbnail]
20
+ @formats = parse_formats(data['formats'] || data[:formats] || [])
21
+ @subtitles = parse_subtitles(data['subtitles'] || data[:subtitles] || {})
22
+ @url = data['url'] || data[:url]
23
+ @ext = data['ext'] || data[:ext]
24
+ @webpage_url = data['webpage_url'] || data[:webpage_url]
25
+ end
26
+
27
+ def available_formats
28
+ @formats.map { |f| f[:format_id] }
29
+ end
30
+
31
+ def available_qualities
32
+ @formats.map { |f| f[:quality] }.uniq.compact
33
+ end
34
+
35
+ def available_subtitle_languages
36
+ @subtitles.keys
37
+ end
38
+
39
+ def best_format
40
+ @formats.max_by { |f| format_priority(f) }
41
+ end
42
+
43
+ def worst_format
44
+ @formats.min_by { |f| format_priority(f) }
45
+ end
46
+
47
+ def best_video_format
48
+ video_formats.max_by { |f| format_priority(f) }
49
+ end
50
+
51
+ def best_audio_format
52
+ audio_formats.max_by { |f| format_priority(f) }
53
+ end
54
+
55
+ def video_formats
56
+ @formats.select { |f| f[:vcodec] && f[:vcodec] != 'none' }
57
+ end
58
+
59
+ def audio_formats
60
+ @formats.select { |f| f[:acodec] && f[:acodec] != 'none' }
61
+ end
62
+
63
+ def get_format(format_id)
64
+ @formats.find { |f| f[:format_id] == format_id }
65
+ end
66
+
67
+ def get_subtitle(lang)
68
+ @subtitles[lang]
69
+ end
70
+
71
+ def duration_in_seconds
72
+ @duration
73
+ end
74
+
75
+ def duration_formatted
76
+ return nil unless @duration
77
+
78
+ hours = @duration / 3600
79
+ minutes = (@duration % 3600) / 60
80
+ seconds = @duration % 60
81
+
82
+ if hours > 0
83
+ format("%02d:%02d:%02d", hours, minutes, seconds)
84
+ else
85
+ format("%02d:%02d", minutes, seconds)
86
+ end
87
+ end
88
+
89
+ def to_h
90
+ {
91
+ id: @id,
92
+ title: @title,
93
+ fulltitle: @fulltitle,
94
+ description: @description,
95
+ uploader: @uploader,
96
+ uploader_id: @uploader_id,
97
+ duration: @duration,
98
+ duration_formatted: duration_formatted,
99
+ view_count: @view_count,
100
+ like_count: @like_count,
101
+ upload_date: @upload_date,
102
+ thumbnail: @thumbnail,
103
+ formats: @formats,
104
+ subtitles: @subtitles,
105
+ url: @url,
106
+ ext: @ext,
107
+ webpage_url: @webpage_url
108
+ }
109
+ end
110
+
111
+ private
112
+
113
+ def parse_duration(duration)
114
+ return nil unless duration
115
+ duration.is_a?(Numeric) ? duration.to_i : duration.to_s.to_i
116
+ end
117
+
118
+ def parse_formats(formats_data)
119
+ return [] unless formats_data.is_a?(Array)
120
+
121
+ formats_data.map do |format|
122
+ {
123
+ format_id: format['format_id'] || format[:format_id],
124
+ format_note: format['format_note'] || format[:format_note],
125
+ ext: format['ext'] || format[:ext],
126
+ url: format['url'] || format[:url],
127
+ width: format['width'] || format[:width],
128
+ height: format['height'] || format[:height],
129
+ fps: format['fps'] || format[:fps],
130
+ vcodec: format['vcodec'] || format[:vcodec],
131
+ acodec: format['acodec'] || format[:acodec],
132
+ tbr: format['tbr'] || format[:tbr],
133
+ abr: format['abr'] || format[:abr],
134
+ vbr: format['vbr'] || format[:vbr],
135
+ filesize: format['filesize'] || format[:filesize],
136
+ quality: format['quality'] || format[:quality],
137
+ protocol: format['protocol'] || format[:protocol]
138
+ }
139
+ end
140
+ end
141
+
142
+ def parse_subtitles(subtitles_data)
143
+ return {} unless subtitles_data.is_a?(Hash)
144
+
145
+ subtitles_data.transform_values do |subtitle_formats|
146
+ subtitle_formats.map do |sub|
147
+ {
148
+ ext: sub['ext'] || sub[:ext],
149
+ url: sub['url'] || sub[:url],
150
+ name: sub['name'] || sub[:name]
151
+ }
152
+ end
153
+ end
154
+ end
155
+
156
+ def format_priority(format)
157
+ # Priority calculation based on multiple factors
158
+ priority = 0
159
+
160
+ # Video quality
161
+ if format[:height]
162
+ priority += format[:height] * 100
163
+ end
164
+
165
+ # Bitrate
166
+ if format[:tbr]
167
+ priority += format[:tbr]
168
+ end
169
+
170
+ # Prefer certain codecs
171
+ if format[:vcodec]
172
+ priority += 10 if format[:vcodec].include?('avc')
173
+ priority += 5 if format[:vcodec].include?('vp9')
174
+ end
175
+
176
+ priority
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,269 @@
1
+ require 'open3'
2
+ require 'json'
3
+ require 'shellwords'
4
+
5
+ module YoutubeRb
6
+ class YtdlpWrapper
7
+ class YtdlpNotFoundError < StandardError; end
8
+ class YtdlpError < StandardError; end
9
+
10
+ attr_reader :options
11
+
12
+ def initialize(options = {})
13
+ @options = options.is_a?(Options) ? options : Options.new(**options)
14
+ check_ytdlp_installation!
15
+ end
16
+
17
+ # Extract video information using yt-dlp
18
+ # @param url [String] Video URL
19
+ # @return [Hash] Video information
20
+ def extract_info(url)
21
+ args = build_info_args(url)
22
+ output, status = execute_ytdlp(args)
23
+
24
+ raise YtdlpError, "Failed to extract info: #{output}" unless status.success?
25
+
26
+ JSON.parse(output)
27
+ rescue JSON::ParserError => e
28
+ raise YtdlpError, "Failed to parse yt-dlp output: #{e.message}"
29
+ end
30
+
31
+ # Download video using yt-dlp
32
+ # @param url [String] Video URL
33
+ # @param output_path [String] Output file path
34
+ # @return [String] Path to downloaded file
35
+ def download(url, output_path = nil)
36
+ args = build_download_args(url, output_path)
37
+ output, status = execute_ytdlp(args, show_progress: true)
38
+
39
+ unless status.success?
40
+ raise YtdlpError, "Download failed: #{output}"
41
+ end
42
+
43
+ # Return the actual output path
44
+ output_path || detect_output_file(output)
45
+ end
46
+
47
+ # Download video segment using yt-dlp
48
+ # @param url [String] Video URL
49
+ # @param start_time [Integer] Start time in seconds
50
+ # @param end_time [Integer] End time in seconds
51
+ # @param output_path [String, nil] Output file path
52
+ # @return [String] Path to downloaded segment
53
+ def download_segment(url, start_time, end_time, output_path = nil)
54
+ args = build_segment_args(url, start_time, end_time, output_path)
55
+ output, status = execute_ytdlp(args, show_progress: true)
56
+
57
+ unless status.success?
58
+ raise YtdlpError, "Segment download failed: #{output}"
59
+ end
60
+
61
+ output_path || detect_output_file(output)
62
+ end
63
+
64
+ # Check if yt-dlp is installed
65
+ # @return [Boolean]
66
+ def self.available?
67
+ system('which yt-dlp > /dev/null 2>&1') ||
68
+ system('which youtube-dl > /dev/null 2>&1')
69
+ end
70
+
71
+ # Get yt-dlp version
72
+ # @return [String] Version string
73
+ def self.version
74
+ output, status = Open3.capture2('yt-dlp', '--version')
75
+ status.success? ? output.strip : 'unknown'
76
+ rescue
77
+ 'not installed'
78
+ end
79
+
80
+ private
81
+
82
+ def check_ytdlp_installation!
83
+ unless self.class.available?
84
+ raise YtdlpNotFoundError, <<~MSG
85
+ yt-dlp is not installed. Please install it:
86
+
87
+ # Using pip:
88
+ pip install -U yt-dlp
89
+
90
+ # Or using pipx:
91
+ pipx install yt-dlp
92
+
93
+ # Or using homebrew (macOS):
94
+ brew install yt-dlp
95
+
96
+ # Or download binary from:
97
+ https://github.com/yt-dlp/yt-dlp/releases
98
+ MSG
99
+ end
100
+ end
101
+
102
+ def build_info_args(url)
103
+ args = ['yt-dlp', '--dump-json', '--no-playlist']
104
+
105
+ # Add cookies file if specified
106
+ if @options.cookies_file && File.exist?(@options.cookies_file)
107
+ args += ['--cookies', @options.cookies_file]
108
+ end
109
+
110
+ # Add user agent
111
+ if @options.user_agent
112
+ args += ['--user-agent', @options.user_agent]
113
+ end
114
+
115
+ # Add referer
116
+ if @options.referer
117
+ args += ['--referer', @options.referer]
118
+ end
119
+
120
+ # Add authentication
121
+ if @options.username && @options.password
122
+ args += ['--username', @options.username, '--password', @options.password]
123
+ end
124
+
125
+ # Add retries
126
+ args += ['--retries', @options.retries.to_s]
127
+
128
+ args << url
129
+ args
130
+ end
131
+
132
+ def build_download_args(url, output_path)
133
+ args = ['yt-dlp']
134
+
135
+ # Output template
136
+ if output_path
137
+ args += ['-o', output_path]
138
+ else
139
+ template = @options.output_template
140
+ output_dir = @options.output_path
141
+ full_template = File.join(output_dir, template)
142
+ args += ['-o', full_template]
143
+ end
144
+
145
+ # Format selection
146
+ if @options.extract_audio
147
+ args += ['-x', '--audio-format', @options.audio_format]
148
+ args += ['--audio-quality', @options.audio_quality] if @options.audio_quality
149
+ else
150
+ format = build_format_string
151
+ args += ['-f', format] if format
152
+ end
153
+
154
+ # Subtitles
155
+ if @options.write_subtitles
156
+ args << '--write-subs'
157
+ args += ['--sub-langs', @options.subtitle_langs.join(',')]
158
+ args += ['--sub-format', @options.subtitle_format]
159
+ end
160
+
161
+ if @options.write_auto_sub
162
+ args << '--write-auto-subs'
163
+ end
164
+
165
+ # Metadata
166
+ args << '--write-info-json' if @options.write_info_json
167
+ args << '--write-thumbnail' if @options.write_thumbnail
168
+ args << '--write-description' if @options.write_description
169
+
170
+ # Cookies and authentication
171
+ if @options.cookies_file && File.exist?(@options.cookies_file)
172
+ args += ['--cookies', @options.cookies_file]
173
+ end
174
+
175
+ if @options.username && @options.password
176
+ args += ['--username', @options.username, '--password', @options.password]
177
+ end
178
+
179
+ # Network options
180
+ args += ['--user-agent', @options.user_agent] if @options.user_agent
181
+ args += ['--referer', @options.referer] if @options.referer
182
+ args += ['--retries', @options.retries.to_s]
183
+ args += ['--rate-limit', @options.rate_limit] if @options.rate_limit
184
+
185
+ # Filesystem options
186
+ args << '--no-overwrites' if @options.no_overwrites
187
+ args << '--continue' if @options.continue_download
188
+ args << '--no-part' if @options.no_part
189
+
190
+ # Playlist handling
191
+ args << '--no-playlist' if @options.no_playlist
192
+ args << '--yes-playlist' if @options.yes_playlist
193
+
194
+ args << url
195
+ args
196
+ end
197
+
198
+ def build_segment_args(url, start_time, end_time, output_path)
199
+ args = build_download_args(url, output_path)
200
+
201
+ # Add download sections for segment
202
+ # yt-dlp format: *start_time-end_time
203
+ section = "*#{start_time}-#{end_time}"
204
+ args.insert(-2, '--download-sections', section)
205
+
206
+ # Segment cutting mode:
207
+ # :fast (default) - Stream copy mode, cuts at keyframes (10x faster, ±2-5s accuracy)
208
+ # :precise - Re-encoding mode, exact timestamps (slow, frame-perfect)
209
+ #
210
+ # Fast mode: Uses stream copy (ffmpeg -c copy) which is very fast but cuts only at
211
+ # keyframe positions. This means segments may be a few seconds longer.
212
+ # Precise mode: Uses --force-keyframes-at-cuts which forces re-encoding to get
213
+ # frame-accurate cuts. This is VERY slow (especially for 4K video).
214
+ if @options.segment_mode == :precise
215
+ args.insert(-2, '--force-keyframes-at-cuts')
216
+ end
217
+ # For :fast mode, we don't add any flags - yt-dlp uses stream copy by default
218
+
219
+ args
220
+ end
221
+
222
+ def build_format_string
223
+ case @options.quality
224
+ when 'best'
225
+ 'bestvideo+bestaudio/best'
226
+ when 'worst'
227
+ 'worstvideo+worstaudio/worst'
228
+ when /^\d+p$/
229
+ # e.g., "720p", "1080p"
230
+ height = @options.quality.to_i
231
+ "bestvideo[height<=#{height}]+bestaudio/best[height<=#{height}]"
232
+ else
233
+ @options.format || 'best'
234
+ end
235
+ end
236
+
237
+ def execute_ytdlp(args, show_progress: false)
238
+ # Escape arguments for shell
239
+ escaped_args = args.map { |arg| Shellwords.escape(arg) }
240
+
241
+ if show_progress && @options.respond_to?(:verbose) && @options.verbose
242
+ # Show yt-dlp output in real-time
243
+ puts "Executing: #{args.join(' ')}" if ENV['DEBUG']
244
+ system(*args)
245
+ status = $?
246
+ ['', status]
247
+ else
248
+ # Capture output
249
+ stdout, stderr, status = Open3.capture3(*args)
250
+ output = stdout.empty? ? stderr : stdout
251
+ [output, status]
252
+ end
253
+ end
254
+
255
+ def detect_output_file(output)
256
+ # Try to detect the output file from yt-dlp output
257
+ # yt-dlp outputs: [download] Destination: filename.ext
258
+ if match = output.match(/\[download\] Destination: (.+)/)
259
+ return match[1].strip
260
+ end
261
+
262
+ # Fallback: try to find the file in output directory
263
+ # This is less reliable but better than nothing
264
+ output_dir = @options.output_path
265
+ files = Dir.glob(File.join(output_dir, '*')).sort_by { |f| File.mtime(f) }
266
+ files.last
267
+ end
268
+ end
269
+ end
data/lib/youtube-rb.rb ADDED
@@ -0,0 +1,69 @@
1
+ require_relative "youtube-rb/version"
2
+ require_relative "youtube-rb/options"
3
+ require_relative "youtube-rb/video_info"
4
+ require_relative "youtube-rb/extractor"
5
+ require_relative "youtube-rb/ytdlp_wrapper"
6
+ require_relative "youtube-rb/downloader"
7
+ require_relative "youtube-rb/client"
8
+
9
+ module YoutubeRb
10
+ class Error < StandardError; end
11
+ class ExtractionError < Error; end
12
+ class DownloadError < Error; end
13
+ class ValidationError < Error; end
14
+
15
+ # Convenience method to create a new client
16
+ # @param options [Hash] Client options
17
+ # @return [Client] New client instance
18
+ def self.new(**options)
19
+ Client.new(**options)
20
+ end
21
+
22
+ # Quick download method
23
+ # @param url [String] Video URL
24
+ # @param options [Hash] Download options
25
+ # @return [String] Path to downloaded file
26
+ def self.download(url, **options)
27
+ client = Client.new(**options)
28
+ client.download(url)
29
+ end
30
+
31
+ # Quick info extraction method
32
+ # @param url [String] Video URL
33
+ # @return [VideoInfo] Video information
34
+ def self.info(url)
35
+ client = Client.new
36
+ client.info(url)
37
+ end
38
+
39
+ # Quick segment download method
40
+ # @param url [String] Video URL
41
+ # @param start_time [Integer] Start time in seconds
42
+ # @param end_time [Integer] End time in seconds
43
+ # @param options [Hash] Download options
44
+ # @return [String] Path to downloaded segment
45
+ def self.download_segment(url, start_time, end_time, **options)
46
+ client = Client.new(**options)
47
+ client.download_segment(url, start_time, end_time, **options)
48
+ end
49
+
50
+ # Quick batch segment download method
51
+ # @param url [String] Video URL
52
+ # @param segments [Array<Hash>] Array of segment definitions
53
+ # @param options [Hash] Download options
54
+ # @return [Array<String>] Paths to downloaded segments
55
+ def self.download_segments(url, segments, **options)
56
+ client = Client.new(**options)
57
+ client.download_segments(url, segments, **options)
58
+ end
59
+
60
+ # Quick subtitles download method
61
+ # @param url [String] Video URL
62
+ # @param langs [Array<String>, nil] Languages to download
63
+ # @param options [Hash] Download options
64
+ # @return [void]
65
+ def self.download_subtitles(url, langs: nil, **options)
66
+ client = Client.new(**options)
67
+ client.download_subtitles(url, langs: langs, **options)
68
+ end
69
+ end