Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions jsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"vueCompilerOptions": {
"target": 2.7
},
"compilerOptions": {
"strictNullChecks": true
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes vscode actually show undefined and null in type definitions e.g. auto-complete and the popup that appears when you hover over functions.

}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
204 changes: 177 additions & 27 deletions src/renderer/helpers/api/local.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
}
Expand Down Expand Up @@ -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 []
}
Expand Down Expand Up @@ -364,34 +368,177 @@ export async function getLocalChannelCommunity(id) {
}
}

/**
* @param {YT.Channel} channel
*/
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 {Misc.Author} author
* @param {string} channelId
* @param {string} channelName
*/
export function parseLocalChannelVideos(videos, author) {
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
}

/**
* @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: ''
}
Expand All @@ -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)
}
Expand Down
Loading
Loading