rubytube 0.4.0 → 1.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.
@@ -1,97 +1,126 @@
1
- module RubyTube
2
- class Stream
3
- attr_accessor(
4
- :monostate,
5
- :url,
6
- :itag,
7
- :mime_type,
8
- :codecs,
9
- :type,
10
- :subtype,
11
- :file_size,
12
- :is_otf,
13
- :bitrate
14
- )
15
-
16
- def initialize(stream, monostate)
17
- self.monostate = monostate
18
-
19
- self.url = stream['url']
20
- self.itag = stream['itag'].to_i
21
-
22
- self.mime_type, self.codecs = Extractor.mime_type_codec(stream['mimeType'])
23
- self.type, self.subtype = mime_type.split('/')
24
-
25
- self.is_otf = stream['is_otf']
26
- self.bitrate = stream['bitrate']
27
-
28
- self.file_size = stream.fetch('contentLength', 0).to_i
29
- end
30
-
31
- def download(filename: nil, output_dir: nil)
32
- file_path = get_file_path(filename, output_dir)
33
-
34
- return file_path if File.exist?(file_path)
35
-
36
- bytes_remaining = file_size
37
-
38
- File.open(file_path, 'wb') do |f|
39
- begin
40
- Request.stream(url) do |chunk|
41
- bytes_remaining -= chunk.bytesize
42
- f.write(chunk)
43
- end
44
- rescue HTTPError => e
45
- raise e if e.code != 404
46
- end
47
- end
48
-
49
- file_path
50
- end
51
-
52
- def is_audio?
53
- type == 'audio'
54
- end
55
-
56
- def is_video?
57
- type == 'video'
58
- end
59
-
60
- def is_adaptive?
61
- codecs.size % 2 == 1
62
- end
63
-
64
- def is_progressive?
65
- !is_adaptive?
66
- end
67
-
68
- def title
69
- monostate.title
70
- end
71
-
72
- def resolution
73
- stream_format.resolution
74
- end
75
-
76
- private
77
-
78
- def get_file_path(filename, output_dir, prefix = '')
79
- filename = default_filename unless filename
80
-
81
- if prefix
82
- filename = "#{prefix}#{filename}"
83
- end
84
-
85
- output_path = Utils.target_directory(output_dir)
86
- File.join(output_path, filename)
87
- end
88
-
89
- def default_filename
90
- "#{monostate.title}.#{subtype}"
91
- end
92
-
93
- def stream_format
94
- @stream_format ||= StreamFormat.new(itag)
95
- end
96
- end
97
- end
1
+ require "net/http"
2
+ require "uri"
3
+ require "fileutils"
4
+
5
+ module RubyTube
6
+ class Stream
7
+ CHUNK_SIZE = 9 * 1024 * 1024
8
+
9
+ attr_reader :itag, :url, :mime_type, :type, :subtype, :codecs, :bitrate,
10
+ :fps, :width, :height, :resolution, :audio_sample_rate, :duration_ms
11
+
12
+ def initialize(format, yt)
13
+ @yt = yt
14
+ @itag = format["itag"]
15
+ @url = format["url"]
16
+ @mime_type, @codecs = parse_mime_type(format["mimeType"])
17
+ @type, @subtype = @mime_type.split("/")
18
+ @bitrate = format["bitrate"]
19
+ @fps = format["fps"]
20
+ @width = format["width"]
21
+ @height = format["height"]
22
+ @resolution = format["qualityLabel"] || (@height && "#{@height}p")
23
+ @audio_sample_rate = format["audioSampleRate"]&.to_i
24
+ @duration_ms = format["approxDurationMs"]&.to_i
25
+ @content_length = format["contentLength"]&.to_i&.nonzero?
26
+ @otf = format["type"] == "FORMAT_STREAM_TYPE_OTF"
27
+ end
28
+
29
+ alias container subtype
30
+
31
+ def video? = type == "video"
32
+ def audio? = type == "audio"
33
+ def otf? = @otf
34
+
35
+ def video_codec = video? ? codecs.first : nil
36
+ def audio_codec = audio? ? codecs.first : nil
37
+
38
+ def abr = bitrate && "#{bitrate / 1000}kbps"
39
+
40
+ def filesize
41
+ @content_length ||= fetch_filesize
42
+ end
43
+
44
+ def default_filename
45
+ name = (@yt.title || video_id_or_itag).gsub(%r{[/\\:*?"<>|]}, "").strip
46
+ "#{name}.#{audio? && container == "mp4" ? "m4a" : container}"
47
+ end
48
+
49
+ def download(output_path: nil, filename: nil, filename_prefix: nil,
50
+ skip_existing: true, max_retries: 0)
51
+ raise Error, "OTF streams are not supported" if otf?
52
+
53
+ target = File.join(File.expand_path(output_path || Dir.pwd),
54
+ "#{filename_prefix}#{filename || default_filename}")
55
+ return target if skip_existing && File.size?(target)
56
+
57
+ FileUtils.mkdir_p(File.dirname(target))
58
+ total = filesize
59
+ downloaded = 0
60
+ File.open(target, "wb") do |io|
61
+ while downloaded < total
62
+ chunk = fetch_range(downloaded, [downloaded + CHUNK_SIZE, total].min - 1, max_retries)
63
+ io.write(chunk)
64
+ downloaded += chunk.bytesize
65
+ yield chunk, total - downloaded if block_given?
66
+ end
67
+ end
68
+ target
69
+ end
70
+
71
+ def to_s
72
+ attrs = { itag:, mime_type:, resolution:, fps:, abr: }.compact
73
+ "#<RubyTube::Stream #{attrs.map { |k, v| "#{k}=#{v}" }.join(' ')}>"
74
+ end
75
+ alias inspect to_s
76
+
77
+ private
78
+
79
+ def video_id_or_itag = @yt.respond_to?(:video_id) ? @yt.video_id : itag.to_s
80
+
81
+ def parse_mime_type(raw)
82
+ match = raw&.match(%r{([\w-]+/[\w-]+);\s*codecs="([^"]+)"}) or
83
+ raise ExtractError, "unparseable mimeType: #{raw.inspect}"
84
+ [match[1], match[2].split(",").map(&:strip)]
85
+ end
86
+
87
+ def fetch_range(start, stop, max_retries)
88
+ attempts = 0
89
+ begin
90
+ response = http.get(request_path, request_headers("bytes=#{start}-#{stop}"))
91
+ raise Error, "HTTP #{response.code} while downloading itag #{itag}" unless response.is_a?(Net::HTTPSuccess)
92
+
93
+ response.body
94
+ rescue Error, SystemCallError, IOError, Net::OpenTimeout, Net::ReadTimeout => e
95
+ @http&.finish rescue nil
96
+ @http = nil
97
+ attempts += 1
98
+ retry if attempts <= max_retries
99
+ raise e
100
+ end
101
+ end
102
+
103
+ def fetch_filesize
104
+ response = http.get(request_path, request_headers("bytes=0-0"))
105
+ total = response["Content-Range"]&.split("/")&.last&.to_i
106
+ raise ExtractError, "cannot determine filesize for itag #{itag} (HTTP #{response.code})" unless total&.positive?
107
+
108
+ total
109
+ end
110
+
111
+ # googlevideo applies the range= query param and the Range header in sequence, so sending
112
+ # both slices an already-sliced body and 416s on any nonzero offset. Use the header alone.
113
+ def request_path = @request_path ||= URI(url).request_uri
114
+
115
+ def request_headers(range)
116
+ { "Range" => range, "User-Agent" => InnerTube::USER_AGENT }
117
+ end
118
+
119
+ def http
120
+ @http ||= begin
121
+ uri = URI(url)
122
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true)
123
+ end
124
+ end
125
+ end
126
+ end
@@ -1,57 +1,56 @@
1
- module RubyTube
2
- class StreamQuery < Array
3
- attr_reader :streams
4
-
5
- def initialize(fmt_streams)
6
- super
7
- @streams = fmt_streams
8
- end
9
-
10
- def filter(file_extension: nil, only_audio: false, only_video: false, resolution: nil, progressive: false, adaptive: false)
11
- filters = []
12
-
13
- filters << ->(stream) { stream.subtype == file_extension } if file_extension
14
- filters << ->(stream) { stream.is_audio? } if only_audio
15
- filters << ->(stream) { stream.is_video? } if only_video
16
- filters << ->(stream) { stream.resolution == resolution } if resolution
17
- filters << ->(stream) { stream.is_progressive? } if progressive
18
- filters << ->(stream) { stream.is_adaptive? } if adaptive
19
-
20
- r = streams
21
- filters.each do |f|
22
- r = r.select(&f)
23
- end
24
-
25
- StreamQuery.new(r)
26
- end
27
-
28
- def get_by_itag(itag)
29
- streams.find { |s| s.itag == itag }
30
- end
31
-
32
- def get_by_resolution(resolution)
33
- streams.find { |s| s.resolution == resolution }
34
- end
35
-
36
- def order(arg)
37
- case arg
38
- when Symbol
39
- field = arg
40
- dir = :asc
41
- when Hash
42
- field = arg.keys.first
43
- dir = arg[field] == :desc ? :desc : :asc
44
- end
45
-
46
- allowed_fields = [:file_size, :itag, :resolution]
47
- raise InvalidArgumentError unless allowed_fields.include? field
48
-
49
- r = streams
50
- r.sort! {|a, b| a.send(field).to_i <=> b.send(field).to_i }
51
-
52
- r.reverse! if dir == :desc
53
-
54
- StreamQuery.new(r)
55
- end
56
- end
57
- end
1
+ module RubyTube
2
+ class StreamQuery
3
+ include Enumerable
4
+
5
+ def initialize(streams)
6
+ @streams = streams
7
+ end
8
+
9
+ def each(&) = @streams.each(&)
10
+ def size = @streams.size
11
+ def [](index) = @streams[index]
12
+ def last = @streams.last
13
+ def empty? = @streams.empty?
14
+
15
+ def filter(itag: nil, res: nil, fps: nil, mime_type: nil, type: nil, container: nil, abr: nil, &block)
16
+ selected = @streams
17
+ selected = selected.select { |s| s.itag == itag } if itag
18
+ selected = selected.select { |s| s.resolution == res } if res
19
+ selected = selected.select { |s| s.fps == fps } if fps
20
+ selected = selected.select { |s| s.mime_type == mime_type } if mime_type
21
+ selected = selected.select { |s| s.type == type } if type
22
+ selected = selected.select { |s| s.container == container } if container
23
+ selected = selected.select { |s| s.abr == abr } if abr
24
+ selected = selected.select(&block) if block
25
+ StreamQuery.new(selected)
26
+ end
27
+
28
+ def order_by(attribute)
29
+ StreamQuery.new(
30
+ @streams.reject { |s| s.public_send(attribute).nil? }
31
+ .sort_by { |s| sort_key(s.public_send(attribute)) }
32
+ )
33
+ end
34
+
35
+ def get_by_itag(itag) = @streams.find { |s| s.itag == itag }
36
+ def video_streams = filter(type: "video")
37
+ def audio_streams = filter(type: "audio")
38
+
39
+ def best_video(container: "mp4", max_resolution: nil)
40
+ candidates = video_streams.filter(container:)
41
+ candidates = candidates.filter { |s| s.resolution.to_i <= max_resolution } if max_resolution
42
+ candidates.max_by { |s| [s.resolution.to_i, s.fps.to_i, s.video_codec.start_with?("avc1") ? 1 : 0, s.bitrate.to_i] }
43
+ end
44
+
45
+ def best_audio(container: "mp4")
46
+ audio_streams.filter(container:).max_by { |s| s.bitrate.to_i }
47
+ end
48
+
49
+ def to_s = "#<RubyTube::StreamQuery #{@streams.size} streams>"
50
+ alias inspect to_s
51
+
52
+ private
53
+
54
+ def sort_key(value) = value.is_a?(String) ? value[/\d+/].to_i : value
55
+ end
56
+ end
@@ -0,0 +1,28 @@
1
+ module RubyTube
2
+ # One entry of a video listing (channel tab, playlist). Built from the listing only: no per-video request is made
3
+ # until #to_youtube. view_count_text / published_text are YouTube's rounded English strings.
4
+ VideoItem = Data.define(:video_id, :title, :length, :view_count_text, :published_text, :thumbnail_url) do
5
+ def url = "https://www.youtube.com/watch?v=#{video_id}"
6
+ def to_youtube = YouTube.new(video_id)
7
+
8
+ def self.from_lockup(lockup)
9
+ meta = lockup.dig("metadata", "lockupMetadataViewModel")
10
+ # Usually one row ["16K views", "2 days ago"]; collaborations prepend a row of channel names.
11
+ rows = Array(meta&.dig("metadata", "contentMetadataViewModel", "metadataRows"))
12
+ .map { |r| Array(r["metadataParts"]).filter_map { |p| p.dig("text", "content") } }
13
+ parts = rows.find { |r| r.any? { |t| t.include?("view") } } || rows.last || []
14
+ views = parts.find { |t| t.include?("view") }
15
+ badge = Array(lockup.dig("contentImage", "thumbnailViewModel", "overlays"))
16
+ .flat_map { |o| Array(o.dig("thumbnailBottomOverlayViewModel", "badges")) }
17
+ .filter_map { |b| b.dig("thumbnailBadgeViewModel", "text") }
18
+ .find { |t| t.match?(/\A\d+(:\d\d)+\z/) }
19
+ new(video_id: lockup.fetch("contentId"),
20
+ title: meta&.dig("title", "content"),
21
+ length: badge&.split(":")&.reduce(0) { |acc, n| acc * 60 + n.to_i },
22
+ view_count_text: views,
23
+ published_text: (parts - [views]).first,
24
+ thumbnail_url: Array(lockup.dig("contentImage", "thumbnailViewModel", "image", "sources"))
25
+ .max_by { |s| s["width"].to_i }&.dig("url"))
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,95 @@
1
+ module RubyTube
2
+ class YouTube
3
+ VIDEO_ID_PATTERNS = [
4
+ %r{youtu\.be/([0-9A-Za-z_-]{11})},
5
+ %r{youtube\.com/(?:watch\?(?:.*&)?v=|shorts/|embed/|live/)([0-9A-Za-z_-]{11})},
6
+ /\A([0-9A-Za-z_-]{11})\z/
7
+ ].freeze
8
+
9
+ attr_reader :video_id
10
+
11
+ def initialize(url)
12
+ @video_id = self.class.extract_video_id(url)
13
+ @innertube = InnerTube.new
14
+ end
15
+
16
+ def self.extract_video_id(url)
17
+ VIDEO_ID_PATTERNS.each do |pattern|
18
+ match = pattern.match(url)
19
+ return match[1] if match
20
+ end
21
+ raise ExtractError, "could not extract a video id from #{url.inspect}"
22
+ end
23
+
24
+ def player_response
25
+ @player_response ||= @innertube.player(video_id).tap { |data| check_availability(data) }
26
+ end
27
+
28
+ def streams
29
+ @streams ||= begin
30
+ streaming_data = player_response["streamingData"] or
31
+ raise ExtractError, "no streamingData in player response (live stream?)"
32
+ formats = Array(streaming_data["adaptiveFormats"]).select { |f| f["url"] }
33
+ StreamQuery.new(formats.map { |f| Stream.new(f, self) })
34
+ end
35
+ end
36
+
37
+ def download(output_path: nil, filename: nil, container: "mp4", max_resolution: nil,
38
+ audio_only: false, skip_existing: true, max_retries: 0, &progress)
39
+ audio = streams.best_audio(container:) or raise ExtractError, "no #{container} audio stream for #{video_id}"
40
+ return audio.download(output_path:, filename:, skip_existing:, max_retries:, &progress) if audio_only
41
+
42
+ video = streams.best_video(container:, max_resolution:) or
43
+ raise ExtractError, "no #{container} video stream for #{video_id}"
44
+ target = File.join(File.expand_path(output_path || Dir.pwd), filename || video.default_filename)
45
+ return target if skip_existing && File.size?(target)
46
+
47
+ remaining = video.filesize + audio.filesize
48
+ track = lambda do |chunk, _|
49
+ remaining -= chunk.bytesize
50
+ progress&.call(chunk, remaining)
51
+ end
52
+ parts = [video, audio].map do |s|
53
+ s.download(output_path: File.dirname(target), filename: "#{File.basename(target)}.#{s.itag}.part",
54
+ skip_existing: false, max_retries:, &track)
55
+ end
56
+ mux(parts, target)
57
+ target
58
+ ensure
59
+ parts&.each { |part| File.delete(part) if File.exist?(part) }
60
+ end
61
+
62
+ def video_details = player_response.fetch("videoDetails", {})
63
+ def title = video_details["title"]
64
+ def author = video_details["author"]
65
+ def channel_id = video_details["channelId"]
66
+ def channel = Channel.new(channel_id)
67
+ def length = video_details["lengthSeconds"]&.to_i
68
+ def views = video_details["viewCount"]&.to_i
69
+ def description = video_details["shortDescription"]
70
+ def keywords = video_details["keywords"] || []
71
+
72
+ def thumbnail_url
73
+ video_details.dig("thumbnail", "thumbnails")&.max_by { |t| t["width"].to_i }&.dig("url")
74
+ end
75
+
76
+ private
77
+
78
+ def mux(inputs, target)
79
+ ok = system("ffmpeg", "-y", "-loglevel", "error", *inputs.flat_map { |i| ["-i", i] }, "-c", "copy", target)
80
+ return if ok
81
+
82
+ File.delete(target) if File.exist?(target)
83
+ raise Error, ok.nil? ? "ffmpeg not found: install it, or fetch tracks separately via streams.best_video / best_audio" :
84
+ "ffmpeg failed to mux #{target}"
85
+ end
86
+
87
+ def check_availability(data)
88
+ status = data.dig("playabilityStatus", "status")
89
+ return if status == "OK"
90
+
91
+ reason = data.dig("playabilityStatus", "reason") || "unknown reason"
92
+ raise VideoUnavailable.new(status || "NO_STATUS", reason)
93
+ end
94
+ end
95
+ end
data/lib/rubytube.rb CHANGED
@@ -1,69 +1,25 @@
1
- # frozen_string_literal: true
2
-
3
- require 'faraday'
4
- require 'faraday/follow_redirects'
5
-
6
- require_relative 'rubytube/version'
7
-
8
- require_relative 'rubytube/cipher'
9
- require_relative 'rubytube/client'
10
- require_relative 'rubytube/extractor'
11
- require_relative 'rubytube/innertube'
12
- require_relative 'rubytube/monostate'
13
- require_relative 'rubytube/parser'
14
- require_relative 'rubytube/request'
15
- require_relative 'rubytube/stream_format'
16
- require_relative 'rubytube/stream_query'
17
- require_relative 'rubytube/stream'
18
- require_relative 'rubytube/utils'
19
-
20
- module RubyTube
21
- class Error < StandardError; end
22
- class HTMLParseError < StandardError; end
23
- class ExtractError < StandardError; end
24
- class MaxRetriesExceeded < StandardError; end
25
- class VideoUnavailable < StandardError; end
26
- class InvalidArgumentError < ArgumentError; end
27
-
28
- class RegexMatchError < StandardError
29
- def initialize(caller, pattern)
30
- super("Regex match error in #{caller} for pattern #{pattern}")
31
- end
32
- end
33
-
34
- class MembersOnly < StandardError
35
- def initialize(video_id)
36
- super("Members only video: #{video_id}")
37
- end
38
- end
39
-
40
- class RecordingUnavailable < StandardError
41
- def initialize(video_id)
42
- super("Recording unavailable: #{video_id}")
43
- end
44
- end
45
-
46
- class VideoUnavailable < StandardError
47
- def initialize(video_id)
48
- super("Video unavailable: #{video_id}")
49
- end
50
- end
51
-
52
- class VideoPrivate < StandardError
53
- def initialize(video_id)
54
- super("Video is private: #{video_id}")
55
- end
56
- end
57
-
58
- class LiveStreamError < StandardError
59
- def initialize(video_id)
60
- super("Video is a live stream: #{video_id}")
61
- end
62
- end
63
-
64
- class << self
65
- def new(url)
66
- Client.new(url)
67
- end
68
- end
69
- end
1
+ module RubyTube
2
+ VERSION = "1.2.0"
3
+
4
+ class Error < StandardError; end
5
+
6
+ class ExtractError < Error; end
7
+
8
+ class VideoUnavailable < Error
9
+ attr_reader :status, :reason
10
+
11
+ def initialize(status, reason)
12
+ @status = status
13
+ @reason = reason
14
+ super("#{status}: #{reason}")
15
+ end
16
+ end
17
+ end
18
+
19
+ require_relative "rubytube/innertube"
20
+ require_relative "rubytube/stream"
21
+ require_relative "rubytube/stream_query"
22
+ require_relative "rubytube/youtube"
23
+ require_relative "rubytube/video_item"
24
+ require_relative "rubytube/channel"
25
+ require_relative "rubytube/playlist"
metadata CHANGED
@@ -1,80 +1,36 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubytube
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - nightswinger
8
- autorequire:
9
- bindir: exe
8
+ bindir: bin
10
9
  cert_chain: []
11
- date: 2023-09-30 00:00:00.000000000 Z
12
- dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: faraday
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - ">="
18
- - !ruby/object:Gem::Version
19
- version: 2.0.1
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - ">="
25
- - !ruby/object:Gem::Version
26
- version: 2.0.1
27
- - !ruby/object:Gem::Dependency
28
- name: faraday-follow_redirects
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - ">="
32
- - !ruby/object:Gem::Version
33
- version: '0'
34
- type: :runtime
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - ">="
39
- - !ruby/object:Gem::Version
40
- version: '0'
41
- description:
10
+ date: 2026-09-16 00:00:00.000000000 Z
11
+ dependencies: []
42
12
  email:
43
13
  - stardustkids83@gmail.com
44
14
  executables: []
45
15
  extensions: []
46
16
  extra_rdoc_files: []
47
17
  files:
48
- - ".rspec"
49
- - ".standard.yml"
50
- - CHANGELOG.md
51
- - CODE_OF_CONDUCT.md
52
- - Gemfile
53
18
  - LICENSE.txt
54
19
  - README.md
55
- - Rakefile
56
20
  - lib/rubytube.rb
57
- - lib/rubytube/cipher.rb
58
- - lib/rubytube/client.rb
59
- - lib/rubytube/extractor.rb
21
+ - lib/rubytube/channel.rb
60
22
  - lib/rubytube/innertube.rb
61
- - lib/rubytube/monostate.rb
62
- - lib/rubytube/parser.rb
63
- - lib/rubytube/request.rb
23
+ - lib/rubytube/playlist.rb
64
24
  - lib/rubytube/stream.rb
65
- - lib/rubytube/stream_format.rb
66
25
  - lib/rubytube/stream_query.rb
67
- - lib/rubytube/utils.rb
68
- - lib/rubytube/version.rb
69
- - sig/rubytube.rbs
26
+ - lib/rubytube/video_item.rb
27
+ - lib/rubytube/youtube.rb
70
28
  homepage: https://github.com/nightswinger/rubytube
71
29
  licenses:
72
30
  - MIT
73
31
  metadata:
74
32
  homepage_uri: https://github.com/nightswinger/rubytube
75
33
  source_code_uri: https://github.com/nightswinger/rubytube
76
- changelog_uri: https://github.com/nightswinger/rubytube
77
- post_install_message:
78
34
  rdoc_options: []
79
35
  require_paths:
80
36
  - lib
@@ -82,16 +38,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
82
38
  requirements:
83
39
  - - ">="
84
40
  - !ruby/object:Gem::Version
85
- version: 2.7.0
41
+ version: '3.4'
86
42
  required_rubygems_version: !ruby/object:Gem::Requirement
87
43
  requirements:
88
44
  - - ">="
89
45
  - !ruby/object:Gem::Version
90
46
  version: '0'
91
47
  requirements: []
92
- rubygems_version: 3.4.10
93
- signing_key:
48
+ rubygems_version: 3.6.2
94
49
  specification_version: 4
95
- summary: RubyTube is a Ruby adaptation of pytube, enabling simplified downloading
96
- and streaming of YouTube videos in a Ruby-friendly manner.
50
+ summary: 'Download YouTube videos from Ruby: picks the best video and audio tracks
51
+ and muxes them with ffmpeg.'
97
52
  test_files: []