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.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -21
- data/README.md +91 -28
- data/lib/rubytube/channel.rb +82 -0
- data/lib/rubytube/innertube.rb +83 -105
- data/lib/rubytube/playlist.rb +111 -0
- data/lib/rubytube/stream.rb +126 -95
- data/lib/rubytube/stream_query.rb +56 -61
- data/lib/rubytube/video_item.rb +28 -0
- data/lib/rubytube/youtube.rb +95 -0
- data/lib/rubytube.rb +25 -27
- metadata +12 -58
- data/.rspec +0 -3
- data/.standard.yml +0 -3
- data/CHANGELOG.md +0 -5
- data/CODE_OF_CONDUCT.md +0 -84
- data/Gemfile +0 -12
- data/Rakefile +0 -10
- data/lib/rubytube/cipher.rb +0 -370
- data/lib/rubytube/client.rb +0 -173
- data/lib/rubytube/error.rb +0 -49
- data/lib/rubytube/extractor.rb +0 -171
- data/lib/rubytube/monostate.rb +0 -5
- data/lib/rubytube/parser.rb +0 -159
- data/lib/rubytube/request.rb +0 -73
- data/lib/rubytube/stream_format.rb +0 -152
- data/lib/rubytube/utils.rb +0 -24
- data/lib/rubytube/version.rb +0 -5
- data/sig/rubytube.rbs +0 -4
data/lib/rubytube/stream.rb
CHANGED
|
@@ -1,95 +1,126 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
def initialize(
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
StreamQuery.new(
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
require_relative "rubytube/
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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:
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- nightswinger
|
|
8
|
-
|
|
9
|
-
bindir: exe
|
|
8
|
+
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
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/
|
|
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/
|
|
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/
|
|
69
|
-
- lib/rubytube/
|
|
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:
|
|
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.
|
|
94
|
-
signing_key:
|
|
48
|
+
rubygems_version: 3.6.2
|
|
95
49
|
specification_version: 4
|
|
96
|
-
summary:
|
|
97
|
-
and
|
|
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
data/.standard.yml
DELETED