Support YouTube using PageHeader on user channels not just auto-generated ones (#4543)

* Support YouTube using PageHeader on user channels not just auto-generated ones

* Bump YouTube.js to 9.0.2 as requested
This commit is contained in:
absidue 2024-02-09 17:39:01 +01:00 committed by GitHub
parent 3bce022616
commit 30248d6bbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 208 additions and 128 deletions

View File

@ -1,5 +1,8 @@
{
"vueCompilerOptions": {
"target": 2.7
},
"compilerOptions": {
"strictNullChecks": true
}
}

View File

@ -77,7 +77,7 @@
"vue-observe-visibility": "^1.0.0",
"vue-router": "^3.6.5",
"vuex": "^3.6.2",
"youtubei.js": "^8.2.0"
"youtubei.js": "^9.0.2"
},
"devDependencies": {
"@babel/core": "^7.23.9",

View File

@ -290,7 +290,9 @@ export async function getLocalChannelVideos(id) {
// if the channel doesn't have a videos tab, YouTube returns the home tab instead
// so we need to check that we got the right tab
if (videosTab.current_tab?.endpoint.metadata.url?.endsWith('/videos')) {
return parseLocalChannelVideos(videosTab.videos, videosTab.header.author)
const { id: channelId = id, name } = parseLocalChannelHeader(videosTab)
return parseLocalChannelVideos(videosTab.videos, channelId, name)
} else {
return []
}
@ -320,7 +322,9 @@ export async function getLocalChannelLiveStreams(id) {
// if the channel doesn't have a live tab, YouTube returns the home tab instead
// so we need to check that we got the right tab
if (liveStreamsTab.current_tab?.endpoint.metadata.url?.endsWith('/streams')) {
return parseLocalChannelVideos(liveStreamsTab.videos, liveStreamsTab.header.author)
const { id: channelId = id, name } = parseLocalChannelHeader(liveStreamsTab)
return parseLocalChannelVideos(liveStreamsTab.videos, channelId, name)
} else {
return []
}
@ -365,16 +369,158 @@ export async function getLocalChannelCommunity(id) {
}
/**
* @param {import('youtubei.js').YTNodes.Video[]} videos
* @param {Misc.Author} author
* @param {YT.Channel} channel
*/
export function parseLocalChannelVideos(videos, author) {
export function parseLocalChannelHeader(channel) {
/** @type {string=} */
let id
/** @type {string} */
let name
/** @type {string=} */
let thumbnailUrl
/** @type {string=} */
let bannerUrl
/** @type {string=} */
let subscriberText
/** @type {string[]} */
const tags = []
switch (channel.header.type) {
case 'C4TabbedHeader': {
// example: Linus Tech Tips
// https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw
/**
* @type {import('youtubei.js').YTNodes.C4TabbedHeader}
*/
const header = channel.header
id = header.author.id
name = header.author.name
thumbnailUrl = header.author.best_thumbnail.url
bannerUrl = header.banner?.[0]?.url
subscriberText = header.subscribers?.text
break
}
case 'CarouselHeader': {
// examples: Music and YouTube Gaming
// https://www.youtube.com/channel/UC-9-kyTW8ZkZNDHQJ6FgpwQ
// https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg
/**
* @type {import('youtubei.js').YTNodes.CarouselHeader}
*/
const header = channel.header
/**
* @type {import('youtubei.js').YTNodes.TopicChannelDetails}
*/
const topicChannelDetails = header.contents.find(node => node.type === 'TopicChannelDetails')
name = topicChannelDetails.title.text
subscriberText = topicChannelDetails.subtitle.text
thumbnailUrl = topicChannelDetails.avatar[0].url
if (channel.metadata.external_id) {
id = channel.metadata.external_id
} else {
id = topicChannelDetails.subscribe_button.channel_id
}
break
}
case 'InteractiveTabbedHeader': {
// example: Minecraft - Topic
// https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg
/**
* @type {import('youtubei.js').YTNodes.InteractiveTabbedHeader}
*/
const header = channel.header
name = header.title.text
thumbnailUrl = header.box_art.at(-1).url
bannerUrl = header.banner[0]?.url
const badges = header.badges.map(badge => badge.label).filter(tag => tag)
tags.push(...badges)
id = channel.current_tab?.endpoint.payload.browseId
break
}
case 'PageHeader': {
// example: YouTube Gaming
// https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg
// User channels (an A/B test at the time of writing)
/**
* @type {import('youtubei.js').YTNodes.PageHeader}
*/
const header = channel.header
name = header.content.title.text.text
if (header.content.image) {
if (header.content.image.type === 'ContentPreviewImageView') {
/** @type {import('youtubei.js').YTNodes.ContentPreviewImageView} */
const image = header.content.image
thumbnailUrl = image.image[0].url
} else {
/** @type {import('youtubei.js').YTNodes.DecoratedAvatarView} */
const image = header.content.image
thumbnailUrl = image.avatar?.image[0].url
}
}
if (!thumbnailUrl && channel.metadata.thumbnail) {
thumbnailUrl = channel.metadata.thumbnail[0].url
}
if (header.content.banner) {
bannerUrl = header.content.banner.image[0]?.url
}
if (header.content.actions) {
const modal = header.content.actions.actions_rows[0].actions[0].on_tap.modal
if (modal && modal.type === 'ModalWithTitleAndButton') {
/** @type {import('youtubei.js').YTNodes.ModalWithTitleAndButton} */
const typedModal = modal
id = typedModal.button.endpoint.next_endpoint?.payload.browseId
}
} else if (channel.metadata.external_id) {
id = channel.metadata.external_id
}
if (header.content.metadata) {
subscriberText = header.content.metadata.metadata_rows[0].metadata_parts[1].text.text
}
break
}
}
return {
id,
name,
thumbnailUrl,
bannerUrl,
subscriberText,
tags
}
}
/**
* @param {import('youtubei.js').YTNodes.Video[]} videos
* @param {string} channelId
* @param {string} channelName
*/
export function parseLocalChannelVideos(videos, channelId, channelName) {
const parsedVideos = videos.map(parseLocalListVideo)
// fix empty author info
parsedVideos.forEach(video => {
video.author = author.name
video.authorId = author.id
video.author = channelName
video.authorId = channelId
})
return parsedVideos
@ -382,16 +528,17 @@ export function parseLocalChannelVideos(videos, author) {
/**
* @param {import('youtubei.js').YTNodes.ReelItem[]} shorts
* @param {Misc.Author} author
* @param {string} channelId
* @param {string} channelName
*/
export function parseLocalChannelShorts(shorts, author) {
export function parseLocalChannelShorts(shorts, channelId, channelName) {
return shorts.map(short => {
return {
type: 'video',
videoId: short.id,
title: short.title.text,
author: author.name,
authorId: author.id,
author: channelName,
authorId: channelId,
viewCount: parseLocalSubscriberCount(short.views.text),
lengthSeconds: ''
}
@ -405,40 +552,43 @@ export function parseLocalChannelShorts(shorts, author) {
/**
* @param {Playlist|GridPlaylist} playlist
* @param {Misc.Author} author
* @param {string} channelId
* @param {string} chanelName
*/
export function parseLocalListPlaylist(playlist, author = undefined) {
let channelName
let channelId = null
/** @type {import('youtubei.js').YTNodes.PlaylistVideoThumbnail} */
const thumbnailRenderer = playlist.thumbnail_renderer
export function parseLocalListPlaylist(playlist, channelId = undefined, channelName = undefined) {
let internalChannelName
let internalChannelId = null
if (playlist.author && playlist.author.id !== 'N/A') {
if (playlist.author instanceof Misc.Text) {
channelName = playlist.author.text
internalChannelName = playlist.author.text
if (author) {
channelId = author.id
if (channelId) {
internalChannelId = channelId
}
} else {
channelName = playlist.author.name
channelId = playlist.author.id
internalChannelName = playlist.author.name
internalChannelId = playlist.author.id
}
} else if (author) {
channelName = author.name
channelId = author.id
} else if (channelId || channelName) {
internalChannelName = channelName
internalChannelId = channelId
} else if (playlist.author?.name) {
// auto-generated album playlists don't have an author
// so in search results, the author text is "Playlist" and doesn't have a link or channel ID
channelName = playlist.author.name
internalChannelName = playlist.author.name
}
/** @type {import('youtubei.js').YTNodes.PlaylistVideoThumbnail} */
const thumbnailRenderer = playlist.thumbnail_renderer
return {
type: 'playlist',
dataSource: 'local',
title: playlist.title.text,
thumbnail: thumbnailRenderer ? thumbnailRenderer.thumbnail[0].url : playlist.thumbnails[0].url,
channelName,
channelId,
channelName: internalChannelName,
channelId: internalChannelId,
playlistId: playlist.id,
videoCount: extractNumberFromString(playlist.video_count.text)
}

View File

@ -25,6 +25,7 @@ import {
import {
getLocalChannel,
getLocalChannelId,
parseLocalChannelHeader,
parseLocalChannelShorts,
parseLocalChannelVideos,
parseLocalCommunityPosts,
@ -532,90 +533,22 @@ export default defineComponent({
return
}
let channelId
let subscriberText = null
let tags = []
const parsedHeader = parseLocalChannelHeader(channel)
switch (channel.header.type) {
case 'C4TabbedHeader': {
// example: Linus Tech Tips
// https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw
const channelId = parsedHeader.id ?? this.id
const subscriberText = parsedHeader.subscriberText ?? null
let tags = parsedHeader.tags
/**
* @type {import('youtubei.js').YTNodes.C4TabbedHeader}
*/
const header = channel.header
channelThumbnailUrl = parsedHeader.thumbnailUrl ?? this.subscriptionInfo?.thumbnail
channelName = parsedHeader.name ?? this.subscriptionInfo?.name
channelId = header.author.id
channelName = header.author.name
channelThumbnailUrl = header.author.best_thumbnail.url
subscriberText = header.subscribers?.text
break
}
case 'CarouselHeader': {
// examples: Music and YouTube Gaming
// https://www.youtube.com/channel/UC-9-kyTW8ZkZNDHQJ6FgpwQ
// https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg
/**
* @type {import('youtubei.js').YTNodes.CarouselHeader}
*/
const header = channel.header
/**
* @type {import('youtubei.js').YTNodes.TopicChannelDetails}
*/
const topicChannelDetails = header.contents.find(node => node.type === 'TopicChannelDetails')
channelName = topicChannelDetails.title.text
subscriberText = topicChannelDetails.subtitle.text
channelThumbnailUrl = topicChannelDetails.avatar[0].url
if (channel.metadata.external_id) {
channelId = channel.metadata.external_id
} else {
channelId = topicChannelDetails.subscribe_button.channel_id
}
break
}
case 'InteractiveTabbedHeader': {
// example: Minecraft - Topic
// https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg
/**
* @type {import('youtubei.js').YTNodes.InteractiveTabbedHeader}
*/
const header = channel.header
channelName = header.title.text
channelId = this.id
channelThumbnailUrl = header.box_art.at(-1).url
const badges = header.badges.map(badge => badge.label).filter(tag => tag)
tags.push(...badges)
break
}
case 'PageHeader': {
// example: YouTube Gaming (an A/B test at the time of writing)
// https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg
/**
* @type {import('youtubei.js').YTNodes.PageHeader}
*/
const header = channel.header
channelName = header.content.title.text
channelThumbnailUrl = header.content.image.image[0].url
channelId = this.id
break
}
}
if (channelThumbnailUrl.startsWith('//')) {
if (channelThumbnailUrl?.startsWith('//')) {
channelThumbnailUrl = `https:${channelThumbnailUrl}`
}
this.channelName = channelName
this.thumbnailUrl = channelThumbnailUrl
this.bannerUrl = parsedHeader.bannerUrl ?? null
this.isFamilyFriendly = !!channel.metadata.is_family_safe
if (channel.metadata.tags) {
@ -646,12 +579,6 @@ export default defineComponent({
this.updateSubscriptionDetails({ channelThumbnailUrl, channelName, channelId })
if (channel.header.banner?.length > 0) {
this.bannerUrl = channel.header.banner[0].url
} else {
this.bannerUrl = null
}
let relatedChannels = channel.channels.map(({ author }) => ({
name: author.name,
id: author.id,
@ -837,7 +764,7 @@ export default defineComponent({
return
}
this.latestVideos = parseLocalChannelVideos(videosTab.videos, channel.header.author)
this.latestVideos = parseLocalChannelVideos(videosTab.videos, this.id, this.channelName)
this.videoContinuationData = videosTab.has_continuation ? videosTab : null
this.isElementListLoading = false
} catch (err) {
@ -862,7 +789,7 @@ export default defineComponent({
*/
const continuation = await this.videoContinuationData.getContinuation()
this.latestVideos = this.latestVideos.concat(parseLocalChannelVideos(continuation.videos, this.channelInstance.header.author))
this.latestVideos = this.latestVideos.concat(parseLocalChannelVideos(continuation.videos, this.id, this.channelName))
this.videoContinuationData = continuation.has_continuation ? continuation : null
} catch (err) {
console.error(err)
@ -895,7 +822,7 @@ export default defineComponent({
return
}
this.latestShorts = parseLocalChannelShorts(shortsTab.videos, channel.header.author)
this.latestShorts = parseLocalChannelShorts(shortsTab.videos, this.id, this.channelName)
this.shortContinuationData = shortsTab.has_continuation ? shortsTab : null
this.isElementListLoading = false
} catch (err) {
@ -920,7 +847,7 @@ export default defineComponent({
*/
const continuation = await this.shortContinuationData.getContinuation()
this.latestShorts.push(...parseLocalChannelShorts(continuation.videos, this.channelInstance.header.author))
this.latestShorts.push(...parseLocalChannelShorts(continuation.videos, this.id, this.channelName))
this.shortContinuationData = continuation.has_continuation ? continuation : null
} catch (err) {
console.error(err)
@ -953,7 +880,7 @@ export default defineComponent({
return
}
this.latestLive = parseLocalChannelVideos(liveTab.videos, channel.header.author)
this.latestLive = parseLocalChannelVideos(liveTab.videos, this.id, this.channelName)
this.liveContinuationData = liveTab.has_continuation ? liveTab : null
this.isElementListLoading = false
} catch (err) {
@ -978,7 +905,7 @@ export default defineComponent({
*/
const continuation = await this.liveContinuationData.getContinuation()
this.latestLive.push(...parseLocalChannelVideos(continuation.videos, this.channelInstance.header.author))
this.latestLive.push(...parseLocalChannelVideos(continuation.videos, this.id, this.channelName))
this.liveContinuationData = continuation.has_continuation ? continuation : null
} catch (err) {
console.error(err)
@ -1270,7 +1197,7 @@ export default defineComponent({
return
}
this.latestPlaylists = playlistsTab.playlists.map(playlist => parseLocalListPlaylist(playlist, channel.header.author))
this.latestPlaylists = playlistsTab.playlists.map(playlist => parseLocalListPlaylist(playlist, this.id, this.channelName))
this.playlistContinuationData = playlistsTab.has_continuation ? playlistsTab : null
this.isElementListLoading = false
} catch (err) {
@ -1295,7 +1222,7 @@ export default defineComponent({
*/
const continuation = await this.playlistContinuationData.getContinuation()
const parsedPlaylists = continuation.playlists.map(playlist => parseLocalListPlaylist(playlist, this.channelInstance.header.author))
const parsedPlaylists = continuation.playlists.map(playlist => parseLocalListPlaylist(playlist, this.id, this.channelName))
this.latestPlaylists = this.latestPlaylists.concat(parsedPlaylists)
this.playlistContinuationData = continuation.has_continuation ? continuation : null
} catch (err) {
@ -1393,7 +1320,7 @@ export default defineComponent({
return
}
this.latestReleases = releaseTab.playlists.map(playlist => parseLocalListPlaylist(playlist, channel.header.author))
this.latestReleases = releaseTab.playlists.map(playlist => parseLocalListPlaylist(playlist, this.id, this.channelName))
this.releaseContinuationData = releaseTab.has_continuation ? releaseTab : null
this.isElementListLoading = false
} catch (err) {
@ -1418,7 +1345,7 @@ export default defineComponent({
*/
const continuation = await this.releaseContinuationData.getContinuation()
const parsedReleases = continuation.playlists.map(playlist => parseLocalListPlaylist(playlist, this.channelInstance.header.author))
const parsedReleases = continuation.playlists.map(playlist => parseLocalListPlaylist(playlist, this.id, this.channelName))
this.latestReleases = this.latestReleases.concat(parsedReleases)
this.releaseContinuationData = continuation.has_continuation ? continuation : null
} catch (err) {
@ -1506,7 +1433,7 @@ export default defineComponent({
return
}
this.latestPodcasts = podcastTab.playlists.map(playlist => parseLocalListPlaylist(playlist, channel.header.author))
this.latestPodcasts = podcastTab.playlists.map(playlist => parseLocalListPlaylist(playlist, this.id, this.channelName))
this.podcastContinuationData = podcastTab.has_continuation ? podcastTab : null
this.isElementListLoading = false
} catch (err) {
@ -1531,7 +1458,7 @@ export default defineComponent({
*/
const continuation = await this.podcastContinuationData.getContinuation()
const parsedPodcasts = continuation.playlists.map(playlist => parseLocalListPlaylist(playlist, this.channelInstance.header.author))
const parsedPodcasts = continuation.playlists.map(playlist => parseLocalListPlaylist(playlist, this.id, this.channelName))
this.latestPodcasts = this.latestPodcasts.concat(parsedPodcasts)
this.releaseContinuationData = continuation.has_continuation ? continuation : null
} catch (err) {
@ -1857,7 +1784,7 @@ export default defineComponent({
if (item.type === 'Video') {
return parseLocalListVideo(item)
} else {
return parseLocalListPlaylist(item, this.channelInstance.header.author)
return parseLocalListPlaylist(item, this.id, this.channelName)
}
})

View File

@ -8850,10 +8850,10 @@ yocto-queue@^1.0.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
youtubei.js@^8.2.0:
version "8.2.0"
resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-8.2.0.tgz#5b173f41fbe6240bb44cb733ce2c1f24e0b072ca"
integrity sha512-i/F4PEURSQmSYCQCo4dWKxOCZXhqkgAuGzNG2RUCtGSmlMX8TvwNewVD/JBjH/czdNmh9SJ00onNZMMxHbt+YA==
youtubei.js@^9.0.2:
version "9.0.2"
resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-9.0.2.tgz#77592a1144cdd51bb4258472265f5031b3966162"
integrity sha512-D7GoJmupYaJxTNQyHRWYw8MUdQTxRaa3c7nzM9etWQjaexepFGVlVtwl3CybLx7GopBNtBvr7RxSUUIUyNnYIg==
dependencies:
jintr "^1.1.0"
tslib "^2.5.0"