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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 58f3740a76ceb385e2032f375b37aeb5226b320c20ed0e83c41f531a88f12061
|
|
4
|
+
data.tar.gz: 13e2db3f0c1f019839565066e2cdfb5f0f607a7fba699e934f0181851e198b3f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4d9c6816e65500a5eaf6a67bdae1fac3befc08a34506f28ae21df59b93fb993fcb8ae0767b55dbc2b17e7b9efb0c9b2c62697c48cb6a35c4b8ba461b2d5a5d3a
|
|
7
|
+
data.tar.gz: 434dd62417af5fa89ae403a7ebb8b285d371683f3333d01fd503661320095b5d8b7c2352dbdaf6dd72a91a4d213469ebe6b5cc86e05b107a26a4fb097aa848ba
|
data/LICENSE.txt
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
The MIT License (MIT)
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2023 nightswinger
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in
|
|
13
|
-
all copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
-
THE SOFTWARE.
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 nightswinger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
CHANGED
|
@@ -1,28 +1,91 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
video
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
1
|
+
# rubytube
|
|
2
|
+
|
|
3
|
+
Download YouTube videos from Ruby. YouTube serves video and audio as separate tracks;
|
|
4
|
+
rubytube picks the best of each and muxes them with ffmpeg, or hands you the tracks directly.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
gem install rubytube
|
|
10
|
+
brew install ffmpeg # only needed for muxed video downloads (YouTube#download)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "rubytube"
|
|
17
|
+
|
|
18
|
+
yt = RubyTube::YouTube.new("https://www.youtube.com/watch?v=jNQXAC9IVRw")
|
|
19
|
+
yt.title # => "Me at the zoo"
|
|
20
|
+
yt.length # => 19
|
|
21
|
+
yt.author # => "jawed"
|
|
22
|
+
|
|
23
|
+
# One call: best video + best audio, muxed with ffmpeg (stream copy, no re-encode)
|
|
24
|
+
yt.download(output_path: "~/Movies") # => "~/Movies/Me at the zoo.mp4"
|
|
25
|
+
yt.download(container: "webm", max_resolution: 1080) # vp9 + opus
|
|
26
|
+
yt.download(audio_only: true) # => "Me at the zoo.m4a", no ffmpeg needed
|
|
27
|
+
yt.download { |chunk, bytes_remaining| puts "#{bytes_remaining} bytes to go" }
|
|
28
|
+
|
|
29
|
+
# Individual tracks
|
|
30
|
+
yt.streams.best_video # highest resolution mp4 (video only)
|
|
31
|
+
yt.streams.best_video(container: "webm", max_resolution: 720)
|
|
32
|
+
yt.streams.best_audio # highest bitrate m4a
|
|
33
|
+
yt.streams.best_audio.download(output_path: "~/Music")
|
|
34
|
+
|
|
35
|
+
# StreamQuery is Enumerable — filter/order_by or plain select/sort_by
|
|
36
|
+
yt.streams.video_streams.filter(container: "mp4") { |s| s.fps == 60 }.order_by(:bitrate).last
|
|
37
|
+
yt.streams.audio_streams
|
|
38
|
+
yt.streams.get_by_itag(140)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Channels
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
channel = RubyTube::Channel.new("https://www.youtube.com/@GoogleDevelopers") # also UC..., @handle, /channel/UC...
|
|
45
|
+
channel.name # => "Google for Developers"
|
|
46
|
+
channel.channel_id # => "UC_x5XG1OV2P6uZZ5FSM9Ttw"
|
|
47
|
+
|
|
48
|
+
# Lazy: only as many pages are fetched as you consume
|
|
49
|
+
channel.videos.first(5).map(&:title)
|
|
50
|
+
channel.videos.take_while { |v| v.published_text.end_with?("days ago") }.to_a
|
|
51
|
+
channel.videos.to_a # every video on the Videos tab
|
|
52
|
+
|
|
53
|
+
video = channel.videos.first # RubyTube::VideoItem
|
|
54
|
+
video.video_id; video.title; video.length # length in seconds
|
|
55
|
+
video.view_count_text; video.published_text # "16K views", "2 days ago" (as YouTube shows them)
|
|
56
|
+
video.thumbnail_url; video.url
|
|
57
|
+
video.to_youtube.download(output_path: "~/Movies") # promote to a YouTube for streams/download
|
|
58
|
+
|
|
59
|
+
yt.channel # Channel of a video's uploader
|
|
60
|
+
|
|
61
|
+
# Bulk download; members-only or scheduled videos raise VideoUnavailable
|
|
62
|
+
channel.videos.each { |v| v.to_youtube.download(output_path: "~/Movies") rescue RubyTube::VideoUnavailable }
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Playlists
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
playlist = RubyTube::Playlist.new("https://www.youtube.com/playlist?list=PLOU2XLYxmsIJGErt5rrCqaSGTMyyqNt2H") # also PL..., watch?v=...&list=...
|
|
69
|
+
playlist.title # => "Compressor Head"
|
|
70
|
+
playlist.length # => 9 (videos)
|
|
71
|
+
playlist.views # => 80317
|
|
72
|
+
playlist.last_updated_text # => "Feb 23, 2026" or "3 days ago" (as YouTube shows it)
|
|
73
|
+
playlist.owner; playlist.owner_id; playlist.channel # uploader name, UC..., and its Channel
|
|
74
|
+
playlist.description; playlist.thumbnail_url; playlist.url
|
|
75
|
+
|
|
76
|
+
# Same lazy VideoItem enumeration as Channel#videos; private/deleted entries are listed when YouTube shows them
|
|
77
|
+
playlist.videos.first(5).map(&:title)
|
|
78
|
+
playlist.videos.each { |v| v.to_youtube.download(output_path: "~/Movies") rescue RubyTube::VideoUnavailable }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`Stream#download` writes a single track. Progressive (video+audio in one file) formats
|
|
82
|
+
are no longer served in a downloadable form by YouTube, so they are not exposed.
|
|
83
|
+
|
|
84
|
+
## Development
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
ruby test/rubytube_test.rb # offline tests (recorded innertube fixture)
|
|
88
|
+
ruby scripts/smoke.rb # live smoke test against real YouTube (needs ffmpeg)
|
|
89
|
+
ruby scripts/channel.rb URL # live check of Channel listing
|
|
90
|
+
ruby scripts/playlist.rb URL # live check of Playlist listing
|
|
91
|
+
```
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module RubyTube
|
|
2
|
+
class Channel
|
|
3
|
+
CHANNEL_PATTERNS = [
|
|
4
|
+
%r{youtube\.com/channel/(UC[0-9A-Za-z_-]{22})},
|
|
5
|
+
%r{youtube\.com/(@[^/?#\s]+)},
|
|
6
|
+
/\A(UC[0-9A-Za-z_-]{22})\z/,
|
|
7
|
+
%r{\A(@[^/?#\s]+)\z}
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(url)
|
|
11
|
+
@ref = self.class.extract_channel_ref(url)
|
|
12
|
+
@innertube = InnerTube.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns "UC..." or "@handle" (handles are percent-decoded, so non-ASCII ones read as written).
|
|
16
|
+
def self.extract_channel_ref(url)
|
|
17
|
+
CHANNEL_PATTERNS.each do |pattern|
|
|
18
|
+
match = pattern.match(url)
|
|
19
|
+
return URI.decode_uri_component(match[1]) if match
|
|
20
|
+
end
|
|
21
|
+
raise ExtractError, "could not extract a channel id or handle from #{url.inspect}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def channel_id = channel_metadata["externalId"]
|
|
25
|
+
def name = channel_metadata["title"]
|
|
26
|
+
|
|
27
|
+
# Lazy: continuation pages are fetched only as far as you enumerate.
|
|
28
|
+
def videos
|
|
29
|
+
Enumerator.new do |y|
|
|
30
|
+
items = videos_tab.dig("content", "richGridRenderer", "contents") or
|
|
31
|
+
raise ExtractError, "no video grid in Videos tab of #{@ref}"
|
|
32
|
+
visitor_data = initial_data.dig("responseContext", "webResponseContextExtensionData", "ytConfigData", "visitorData")
|
|
33
|
+
seen = nil
|
|
34
|
+
loop do
|
|
35
|
+
token = nil
|
|
36
|
+
items.each do |item|
|
|
37
|
+
if (lockup = item.dig("richItemRenderer", "content", "lockupViewModel"))
|
|
38
|
+
y << VideoItem.from_lockup(lockup) if lockup["contentType"] == "LOCKUP_CONTENT_TYPE_VIDEO"
|
|
39
|
+
else
|
|
40
|
+
token = InnerTube.continuation_token(item)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
break if token.nil? || token == seen
|
|
44
|
+
|
|
45
|
+
seen = token
|
|
46
|
+
items = Array(@innertube.browse(continuation: token, visitor_data:)
|
|
47
|
+
.dig("onResponseReceivedActions", 0, "appendContinuationItemsAction", "continuationItems"))
|
|
48
|
+
end
|
|
49
|
+
end.lazy
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def page_url
|
|
55
|
+
path = @ref.start_with?("@") ? "@#{URI.encode_uri_component(@ref[1..])}" : "channel/#{@ref}"
|
|
56
|
+
URI("https://www.youtube.com/#{path}/videos")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initial_data
|
|
60
|
+
@initial_data ||= begin
|
|
61
|
+
response = Net::HTTP.get_response(page_url, "User-Agent" => InnerTube::USER_AGENT,
|
|
62
|
+
"Accept-Language" => "en-US,en;q=0.9")
|
|
63
|
+
raise ExtractError, "channel page for #{@ref} returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
64
|
+
|
|
65
|
+
match = response.body.match(%r{ytInitialData\s*=\s*(\{.*?\});</script>}m) or
|
|
66
|
+
raise ExtractError, "no ytInitialData in channel page for #{@ref}"
|
|
67
|
+
JSON.parse(match[1])
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def channel_metadata
|
|
72
|
+
initial_data.dig("metadata", "channelMetadataRenderer") or
|
|
73
|
+
raise ExtractError, "no channel metadata for #{@ref} (channel not found?)"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def videos_tab
|
|
77
|
+
tabs = Array(initial_data.dig("contents", "twoColumnBrowseResultsRenderer", "tabs"))
|
|
78
|
+
tabs.filter_map { |t| t["tabRenderer"] }.find { |t| t["selected"] } or
|
|
79
|
+
raise ExtractError, "no Videos tab for #{@ref}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/rubytube/innertube.rb
CHANGED
|
@@ -1,105 +1,83 @@
|
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
options = {}
|
|
85
|
-
options[:headers] = headers.merge(header)
|
|
86
|
-
|
|
87
|
-
options[:query] = {
|
|
88
|
-
key: api_key,
|
|
89
|
-
contentCheckOk: true,
|
|
90
|
-
racyCheckOk: true
|
|
91
|
-
}.merge(query)
|
|
92
|
-
options[:data] = data
|
|
93
|
-
|
|
94
|
-
resp = Request.post(endpoint, options)
|
|
95
|
-
JSON.parse(resp)
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def player(video_id)
|
|
99
|
-
endpoint = "#{BASE_URL}/player"
|
|
100
|
-
query = {"videoId" => video_id}
|
|
101
|
-
|
|
102
|
-
send(endpoint, query, {context: context})
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
end
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module RubyTube
|
|
6
|
+
class InnerTube
|
|
7
|
+
BASE = "https://www.youtube.com/youtubei/v1"
|
|
8
|
+
|
|
9
|
+
CLIENT_VERSION = "1.02"
|
|
10
|
+
CLIENT_ID = "101"
|
|
11
|
+
USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_3) AppleWebKit/605.1.15 " \
|
|
12
|
+
"(KHTML, like Gecko) Version/26.0 Safari/605.1.15"
|
|
13
|
+
|
|
14
|
+
WEB_CLIENT_ID = "1"
|
|
15
|
+
WEB_CLIENT_VERSION = "2.20260708.00.00"
|
|
16
|
+
|
|
17
|
+
CLIENT_CONTEXT = {
|
|
18
|
+
clientName: "VISIONOS",
|
|
19
|
+
clientVersion: CLIENT_VERSION,
|
|
20
|
+
deviceMake: "Apple",
|
|
21
|
+
deviceModel: "RealityDevice17,1",
|
|
22
|
+
osName: "visionOS",
|
|
23
|
+
osVersion: "26.5.23O471",
|
|
24
|
+
hl: "en",
|
|
25
|
+
gl: "US"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
WEB_CLIENT_CONTEXT = {
|
|
29
|
+
clientName: "WEB",
|
|
30
|
+
clientVersion: WEB_CLIENT_VERSION,
|
|
31
|
+
hl: "en",
|
|
32
|
+
gl: "US"
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# Channel/playlist listing: first page by browse_id (+ params), later pages by continuation.
|
|
36
|
+
# visitor_data (from the previous response) is optional but keeps pagination consistent.
|
|
37
|
+
def browse(continuation: nil, browse_id: nil, params: nil, visitor_data: nil)
|
|
38
|
+
client = visitor_data ? WEB_CLIENT_CONTEXT.merge(visitorData: visitor_data) : WEB_CLIENT_CONTEXT
|
|
39
|
+
body = { context: { client: }, continuation:, browseId: browse_id, params: }.compact
|
|
40
|
+
post("browse", body, client_id: WEB_CLIENT_ID, client_version: WEB_CLIENT_VERSION, visitor_data:)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Continuation token of a listing item, or nil. YouTube has used three shapes for it.
|
|
44
|
+
def self.continuation_token(item)
|
|
45
|
+
endpoint = item.dig("continuationItemRenderer", "continuationEndpoint") ||
|
|
46
|
+
item.dig("continuationItemViewModel", "continuationCommand", "innertubeCommand") or return
|
|
47
|
+
[endpoint, *endpoint.dig("commandExecutorCommand", "commands")]
|
|
48
|
+
.filter_map { |command| command.dig("continuationCommand", "token") }.first
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def player(video_id)
|
|
52
|
+
post("player",
|
|
53
|
+
{ context: { client: CLIENT_CONTEXT.merge(visitorData: visitor_data) },
|
|
54
|
+
videoId: video_id,
|
|
55
|
+
contentCheckOk: true,
|
|
56
|
+
racyCheckOk: true })
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def visitor_data
|
|
60
|
+
@visitor_data ||= post("visitor_id", { context: { client: CLIENT_CONTEXT } })
|
|
61
|
+
.dig("responseContext", "visitorData") or
|
|
62
|
+
raise ExtractError, "could not obtain visitorData"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def post(endpoint, payload, client_id: CLIENT_ID, client_version: CLIENT_VERSION, visitor_data: @visitor_data)
|
|
68
|
+
uri = URI("#{BASE}/#{endpoint}?prettyPrint=false")
|
|
69
|
+
request = Net::HTTP::Post.new(uri)
|
|
70
|
+
request["Content-Type"] = "application/json"
|
|
71
|
+
request["User-Agent"] = USER_AGENT
|
|
72
|
+
request["X-Youtube-Client-Name"] = client_id
|
|
73
|
+
request["X-Youtube-Client-Version"] = client_version
|
|
74
|
+
request["X-Goog-Visitor-Id"] = visitor_data if visitor_data
|
|
75
|
+
request.body = JSON.generate(payload)
|
|
76
|
+
|
|
77
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
|
|
78
|
+
raise ExtractError, "innertube #{endpoint} returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
79
|
+
|
|
80
|
+
JSON.parse(response.body)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module RubyTube
|
|
2
|
+
class Playlist
|
|
3
|
+
PLAYLIST_ID_PATTERNS = [
|
|
4
|
+
/[?&]list=([0-9A-Za-z_-]+)/,
|
|
5
|
+
/\A((?:PL|UU|OLAK5uy_|RD|LL|FL|EC|UL|TL|PU)[0-9A-Za-z_-]{10,})\z/
|
|
6
|
+
].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :playlist_id
|
|
9
|
+
|
|
10
|
+
def initialize(url)
|
|
11
|
+
@playlist_id = self.class.extract_playlist_id(url)
|
|
12
|
+
@innertube = InnerTube.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.extract_playlist_id(url)
|
|
16
|
+
PLAYLIST_ID_PATTERNS.each do |pattern|
|
|
17
|
+
match = pattern.match(url)
|
|
18
|
+
return match[1] if match
|
|
19
|
+
end
|
|
20
|
+
raise ExtractError, "could not extract a playlist id from #{url.inspect}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def url = "https://www.youtube.com/playlist?list=#{playlist_id}"
|
|
24
|
+
def title = metadata["title"]
|
|
25
|
+
def description = metadata["description"]
|
|
26
|
+
def length = stats_text(/([\d,]+) videos?\b/)&.delete(",")&.to_i
|
|
27
|
+
def views = stats_text(/([\d,]+) views?\b/)&.delete(",")&.to_i
|
|
28
|
+
def last_updated_text = stats_text(/(?:Last updated on |Updated )(.+)/)
|
|
29
|
+
def owner = owner_run&.dig("text")&.delete_prefix("by ")
|
|
30
|
+
def owner_id = owner_run&.dig("navigationEndpoint", "browseEndpoint", "browseId")
|
|
31
|
+
def channel = Channel.new(owner_id)
|
|
32
|
+
|
|
33
|
+
def thumbnail_url
|
|
34
|
+
thumbnails = sidebar_primary&.dig("thumbnailRenderer")&.values&.first&.dig("thumbnail", "thumbnails") ||
|
|
35
|
+
initial_data.dig("microformat", "microformatDataRenderer", "thumbnail", "thumbnails")
|
|
36
|
+
Array(thumbnails).max_by { |t| t["width"].to_i }&.dig("url")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Lazy: continuation pages are fetched only as far as you enumerate. Includes unavailable
|
|
40
|
+
# (private/deleted) entries when YouTube lists them; VideoItem#to_youtube raises for those.
|
|
41
|
+
def videos
|
|
42
|
+
Enumerator.new do |y|
|
|
43
|
+
items = first_page_items
|
|
44
|
+
visitor_data = initial_data.dig("responseContext", "visitorData")
|
|
45
|
+
seen = nil
|
|
46
|
+
loop do
|
|
47
|
+
token = nil
|
|
48
|
+
items.each do |item|
|
|
49
|
+
if (lockup = item["lockupViewModel"])
|
|
50
|
+
y << VideoItem.from_lockup(lockup) if lockup["contentType"] == "LOCKUP_CONTENT_TYPE_VIDEO" && lockup["contentId"]
|
|
51
|
+
else
|
|
52
|
+
token = InnerTube.continuation_token(item)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
break if token.nil? || token == seen
|
|
56
|
+
|
|
57
|
+
seen = token
|
|
58
|
+
response = @innertube.browse(continuation: token, visitor_data:)
|
|
59
|
+
items = Array(response.dig("onResponseReceivedActions", 0, "appendContinuationItemsAction", "continuationItems") ||
|
|
60
|
+
response.dig("onResponseReceivedEndpoints", 0, "appendContinuationItemsAction", "continuationItems"))
|
|
61
|
+
end
|
|
62
|
+
end.lazy
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# "wgYCCAA=" is the "show unavailable videos" browse param, so private/deleted entries are listed too.
|
|
68
|
+
def initial_data
|
|
69
|
+
@initial_data ||= @innertube.browse(browse_id: "VL#{playlist_id}", params: "wgYCCAA=").tap do |data|
|
|
70
|
+
next if data["contents"]
|
|
71
|
+
|
|
72
|
+
alert = Array(data["alerts"]).filter_map { |a| a.dig("alertRenderer", "text", "runs", 0, "text") }.first
|
|
73
|
+
raise ExtractError, "playlist #{playlist_id}: #{alert || 'no contents in browse response'}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Video lockups plus the continuation item, which YouTube puts either at the end of the item
|
|
78
|
+
# section or as a sibling section.
|
|
79
|
+
def first_page_items
|
|
80
|
+
sections = initial_data.dig("contents", "twoColumnBrowseResultsRenderer", "tabs", 0, "tabRenderer", "content",
|
|
81
|
+
"sectionListRenderer", "contents") or
|
|
82
|
+
raise ExtractError, "no video list for playlist #{playlist_id}"
|
|
83
|
+
sections.flat_map { |section| section.dig("itemSectionRenderer", "contents") || [section] }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def metadata = initial_data.dig("metadata", "playlistMetadataRenderer") || {}
|
|
87
|
+
def sidebar_primary = initial_data.dig("sidebar", "playlistSidebarRenderer", "items", 0, "playlistSidebarPrimaryInfoRenderer")
|
|
88
|
+
|
|
89
|
+
def header_rows
|
|
90
|
+
Array(initial_data.dig("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "metadata",
|
|
91
|
+
"contentMetadataViewModel", "metadataRows")).flat_map { |row| Array(row["metadataParts"]) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Stats appear as text runs in the sidebar (old layout) and as metadata parts in the page header (new layout).
|
|
95
|
+
def stats_text(pattern)
|
|
96
|
+
texts = Array(sidebar_primary&.dig("stats")).map { |s| s["simpleText"] || Array(s["runs"]).map { |r| r["text"] }.join } +
|
|
97
|
+
header_rows.filter_map { |part| part.dig("text", "content") }
|
|
98
|
+
texts.each { |text| (match = pattern.match(text)) and return match[1] }
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def owner_run
|
|
103
|
+
initial_data.dig("sidebar", "playlistSidebarRenderer", "items", 1, "playlistSidebarSecondaryInfoRenderer",
|
|
104
|
+
"videoOwner", "videoOwnerRenderer", "title", "runs", 0) ||
|
|
105
|
+
header_rows.filter_map { |part| part.dig("avatarStack", "avatarStackViewModel", "text") }.first&.then do |text|
|
|
106
|
+
{ "text" => text["content"],
|
|
107
|
+
"navigationEndpoint" => text.dig("commandRuns", 0, "onTap", "innertubeCommand") }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|