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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 57d1f41767aaf6489fa9c4ed47e82f122da84f3bbf3d7f69821f86d7d15d4d04
4
- data.tar.gz: 8c453e409b21603e87a701acba2725f56b570078d89a9e73d6a643568c6580df
3
+ metadata.gz: 58f3740a76ceb385e2032f375b37aeb5226b320c20ed0e83c41f531a88f12061
4
+ data.tar.gz: 13e2db3f0c1f019839565066e2cdfb5f0f607a7fba699e934f0181851e198b3f
5
5
  SHA512:
6
- metadata.gz: a23289c4db4a278a0c0916cb53ad910e2bb1ecab5969b7d01253a4e881b149df8e84572754c9ff21b41b89504dac0c2bfa051b309cc66fe72278880bf4cdd51e
7
- data.tar.gz: 172741601630ae665a819870ecefd9a0bc908afb393c318f65eb8b28e7e8cf26d85cc20d1aba8144237abec6c9b32977b6d4a33686b6ec1b5942fd00820bac4d
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
- # RubyTube
2
-
3
- [![Gem Version](https://badge.fury.io/rb/rubytube.svg)](https://badge.fury.io/rb/rubytube)
4
-
5
- RubyTube is a Ruby implementation of the popular Python library, pytube. This library facilitates the downloading and streaming of YouTube videos, offering the robust functionality of pytube in a Ruby-friendly format.
6
-
7
- ## Installation
8
-
9
- $ gem install rubytube
10
-
11
- ## Quick Start
12
-
13
- ```ruby
14
- require 'rubytube'
15
-
16
- # Initialize with video URL
17
- video = RubyTube.new('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
18
-
19
- # Download video
20
- video.streams.first.download
21
-
22
- # Filtering streams
23
- video.streams
24
- .filter(progressive: true, file_extension: 'mp4')
25
- .order(resolution: :desc)
26
- .first
27
- .download
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
@@ -1,105 +1,83 @@
1
- module RubyTube
2
- class InnerTube
3
- DEFALUT_CLIENTS = {
4
- "WEB" => {
5
- context: {
6
- client: {
7
- clientName: "WEB",
8
- clientVersion: "2.20200720.00.02"
9
- }
10
- },
11
- header: {"User-Agent": "Mozilla/5.0"},
12
- api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
13
- },
14
- "ANDROID_MUSIC" => {
15
- context: {
16
- client: {
17
- clientName: "ANDROID_MUSIC",
18
- clientVersion: "5.16.51",
19
- androidSdkVersion: 30
20
- }
21
- },
22
- header: {"User-Agent": "com.google.android.apps.youtube.music/"},
23
- api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
24
- },
25
- "ANDROID_EMBED" => {
26
- context: {
27
- client: {
28
- clientName: "ANDROID_EMBEDDED_PLAYER",
29
- clientVersion: "17.31.35",
30
- clientScreen: "EMBED",
31
- androidSdkVersion: 30
32
- }
33
- },
34
- header: {"User-Agent": "com.google.android.youtube/"},
35
- api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
36
- }
37
- }
38
-
39
- BASE_URL = "https://www.youtube.com/youtubei/v1"
40
-
41
- attr_accessor :context, :header, :api_key, :access_token, :refresh_token, :use_oauth, :allow_cache, :expires
42
-
43
- def initialize(client: "WEB", use_oauth: false, allow_cache: false)
44
- self.context = DEFALUT_CLIENTS[client][:context]
45
- self.header = DEFALUT_CLIENTS[client][:header]
46
- self.api_key = DEFALUT_CLIENTS[client][:api_key]
47
- self.use_oauth = use_oauth
48
- self.allow_cache = allow_cache
49
- end
50
-
51
- def cache_tokens
52
- nil unless allow_cache
53
-
54
- # TODO:
55
- end
56
-
57
- def refresh_bearer_token(force: false)
58
- # TODO:
59
- end
60
-
61
- def fetch_bearer_token
62
- # TODO:
63
- end
64
-
65
- def send(endpoint, query, data)
66
- if use_oauth
67
- query.delete(:key)
68
- end
69
-
70
- headers = {
71
- "Content-Type": "application/json"
72
- }
73
-
74
- if use_oauth
75
- if access_token
76
- refresh_bearer_token
77
- headers["Authorization"] = "Bearer #{access_token}"
78
- else
79
- fetch_bearer_token
80
- headers["Authorization"] = "Bearer #{access_token}"
81
- end
82
- end
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