From 91e7102ac385a550eae53a7d7a7abffa8120e06c Mon Sep 17 00:00:00 2001 From: absidue <48293849+absidue@users.noreply.github.com> Date: Sun, 14 Jan 2024 12:29:22 +0100 Subject: [PATCH] Support YouTube using PageHeader on user channels not just auto-generated ones --- jsconfig.json | 3 + package.json | 2 +- src/renderer/helpers/api/local.js | 204 ++++++++++++++++++++++---- src/renderer/views/Channel/Channel.js | 117 +++------------ yarn.lock | 8 +- 5 files changed, 207 insertions(+), 127 deletions(-) diff --git a/jsconfig.json b/jsconfig.json index 8f5ea8a228e3c..6efa53c23ad98 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,5 +1,8 @@ { "vueCompilerOptions": { "target": 2.7 + }, + "compilerOptions": { + "strictNullChecks": true } } diff --git a/package.json b/package.json index eb266f9fe9e4f..2df48039e0b7f 100644 --- a/package.json +++ b/package.json @@ -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.1" }, "devDependencies": { "@babel/core": "^7.23.9", diff --git a/src/renderer/helpers/api/local.js b/src/renderer/helpers/api/local.js index 72095878449bf..19578196816de 100644 --- a/src/renderer/helpers/api/local.js +++ b/src/renderer/helpers/api/local.js @@ -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 [] } @@ -364,17 +368,159 @@ 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 @@ -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) } diff --git a/src/renderer/views/Channel/Channel.js b/src/renderer/views/Channel/Channel.js index 39fc91a0ee4d0..4430a3af08095 100644 --- a/src/renderer/views/Channel/Channel.js +++ b/src/renderer/views/Channel/Channel.js @@ -25,6 +25,7 @@ import { import { getLocalChannel, getLocalChannelId, + parseLocalChannelHeader, parseLocalChannelShorts, parseLocalChannelVideos, parseLocalCommunityPosts, @@ -531,90 +532,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) { @@ -645,12 +578,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 - } - this.relatedChannels = channel.channels.map(({ author }) => { let thumbnailUrl = author.best_thumbnail.url @@ -811,7 +738,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) { @@ -836,7 +763,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) @@ -869,7 +796,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) { @@ -894,7 +821,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) @@ -927,7 +854,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) { @@ -952,7 +879,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) @@ -1244,7 +1171,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) { @@ -1269,7 +1196,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) { @@ -1367,7 +1294,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) { @@ -1392,7 +1319,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) { @@ -1480,7 +1407,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) { @@ -1505,7 +1432,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) { @@ -1831,7 +1758,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) } }) diff --git a/yarn.lock b/yarn.lock index 837ce589a04ba..b775efae8bf8f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8841,10 +8841,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.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-9.0.1.tgz#2aa5637e7692c8afac4a6969e44c46215cb9f10d" + integrity sha512-41ZqulZh5lgMHgCgCiQNqme9kMJDVLQc9pMnoVEtQF9uyaGe05wdfNYmFzzBP1o1Va1IDhtJqmHwGUODxn8w+w== dependencies: jintr "^1.1.0" tslib "^2.5.0"