rubytube 0.5.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,95 +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
- Request.stream(url) do |chunk|
40
- bytes_remaining -= chunk.bytesize
41
- f.write(chunk)
42
- end
43
- rescue HTTPError => e
44
- raise e if e.code != 404
45
- end
46
-
47
- file_path
48
- end
49
-
50
- def is_audio?
51
- type == "audio"
52
- end
53
-
54
- def is_video?
55
- type == "video"
56
- end
57
-
58
- def is_adaptive?
59
- codecs.size % 2 == 1
60
- end
61
-
62
- def is_progressive?
63
- !is_adaptive?
64
- end
65
-
66
- def title
67
- monostate.title
68
- end
69
-
70
- def resolution
71
- stream_format.resolution
72
- end
73
-
74
- private
75
-
76
- def get_file_path(filename, output_dir, prefix = "")
77
- filename ||= default_filename
78
-
79
- if prefix
80
- filename = "#{prefix}#{filename}"
81
- end
82
-
83
- output_path = Utils.target_directory(output_dir)
84
- File.join(output_path, filename)
85
- end
86
-
87
- def default_filename
88
- "#{monostate.title}.#{subtype}"
89
- end
90
-
91
- def stream_format
92
- @stream_format ||= StreamFormat.new(itag)
93
- end
94
- end
95
- 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,61 +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 get_highest_resolution
37
- order(resolution: :desc).first
38
- end
39
-
40
- def order(arg)
41
- case arg
42
- when Symbol
43
- field = arg
44
- dir = :asc
45
- when Hash
46
- field = arg.keys.first
47
- dir = (arg[field] == :desc) ? :desc : :asc
48
- end
49
-
50
- allowed_fields = [:file_size, :itag, :resolution]
51
- raise InvalidArgumentError unless allowed_fields.include? field
52
-
53
- r = streams
54
- r.sort! { |a, b| a.send(field).to_i <=> b.send(field).to_i }
55
-
56
- r.reverse! if dir == :desc
57
-
58
- StreamQuery.new(r)
59
- end
60
- end
61
- 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,27 +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/error"
11
- require_relative "rubytube/extractor"
12
- require_relative "rubytube/innertube"
13
- require_relative "rubytube/monostate"
14
- require_relative "rubytube/parser"
15
- require_relative "rubytube/request"
16
- require_relative "rubytube/stream_format"
17
- require_relative "rubytube/stream_query"
18
- require_relative "rubytube/stream"
19
- require_relative "rubytube/utils"
20
-
21
- module RubyTube
22
- class << self
23
- def new(url)
24
- Client.new(url)
25
- end
26
- end
27
- 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,81 +1,36 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubytube
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.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-10-16 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/error.rb
60
- - lib/rubytube/extractor.rb
21
+ - lib/rubytube/channel.rb
61
22
  - lib/rubytube/innertube.rb
62
- - lib/rubytube/monostate.rb
63
- - lib/rubytube/parser.rb
64
- - lib/rubytube/request.rb
23
+ - lib/rubytube/playlist.rb
65
24
  - lib/rubytube/stream.rb
66
- - lib/rubytube/stream_format.rb
67
25
  - lib/rubytube/stream_query.rb
68
- - lib/rubytube/utils.rb
69
- - lib/rubytube/version.rb
70
- - sig/rubytube.rbs
26
+ - lib/rubytube/video_item.rb
27
+ - lib/rubytube/youtube.rb
71
28
  homepage: https://github.com/nightswinger/rubytube
72
29
  licenses:
73
30
  - MIT
74
31
  metadata:
75
32
  homepage_uri: https://github.com/nightswinger/rubytube
76
33
  source_code_uri: https://github.com/nightswinger/rubytube
77
- changelog_uri: https://github.com/nightswinger/rubytube
78
- post_install_message:
79
34
  rdoc_options: []
80
35
  require_paths:
81
36
  - lib
@@ -83,16 +38,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
83
38
  requirements:
84
39
  - - ">="
85
40
  - !ruby/object:Gem::Version
86
- version: 2.7.0
41
+ version: '3.4'
87
42
  required_rubygems_version: !ruby/object:Gem::Requirement
88
43
  requirements:
89
44
  - - ">="
90
45
  - !ruby/object:Gem::Version
91
46
  version: '0'
92
47
  requirements: []
93
- rubygems_version: 3.4.10
94
- signing_key:
48
+ rubygems_version: 3.6.2
95
49
  specification_version: 4
96
- summary: RubyTube is a Ruby adaptation of pytube, enabling simplified downloading
97
- 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.'
98
52
  test_files: []
data/.rspec DELETED
@@ -1,3 +0,0 @@
1
- --format documentation
2
- --color
3
- --require spec_helper
data/.standard.yml DELETED
@@ -1,3 +0,0 @@
1
- # For available configuration options, see:
2
- # https://github.com/testdouble/standard
3
- ruby_version: 2.6
data/CHANGELOG.md DELETED
@@ -1,5 +0,0 @@
1
- ## [Unreleased]
2
-
3
- ## [0.1.0] - 2023-09-15
4
-
5
- - Initial release