From 291aeff1a6bf3a7a5d6982e627aaafd0d5838042 Mon Sep 17 00:00:00 2001 From: absidue <48293849+absidue@users.noreply.github.com> Date: Wed, 1 Mar 2023 01:39:33 +0100 Subject: [PATCH] Migrate channel related functionality to YouTube.js (#3143) * Migrate channel related functionality to YouTube.js * Better alert handling * Add support for special autogenerated channels * Add support for latest YouTube.js changes * Add support for age restricted channels * Update YouTube.js to 3.0.0 * Obey hide search bar setting for the tag searching * Choose a better parameter name * Allow sharing terminated and age restricted channels * Add handle support for handles on Invidious * Fix the backend fallback * Use a positive parameter name Co-authored-by: PikachuEXE * Fix duplicate tags causing errors * Fix sorting for the Invidious API * Move URL resolving to the channel page * Update YouTube.js to 3.1.0 --------- Co-authored-by: PikachuEXE --- _scripts/webpack.renderer.config.js | 8 +- _scripts/webpack.web.config.js | 15 +- package.json | 3 +- src/renderer/App.js | 7 +- .../components/data-settings/data-settings.js | 43 +- src/renderer/components/top-nav/top-nav.js | 8 +- src/renderer/helpers/api/invidious.js | 31 +- src/renderer/helpers/api/local.js | 197 +++-- src/renderer/helpers/utils.js | 4 + src/renderer/main.js | 2 + src/renderer/store/modules/utils.js | 15 +- src/renderer/views/Channel/Channel.css | 38 +- src/renderer/views/Channel/Channel.js | 685 +++++++++++++----- src/renderer/views/Channel/Channel.vue | 104 ++- .../SubscribedChannels/SubscribedChannels.js | 16 +- .../views/Subscriptions/Subscriptions.js | 81 +-- src/renderer/views/Watch/Watch.js | 24 +- static/locales/en-US.yaml | 10 + yarn.lock | 54 +- 19 files changed, 956 insertions(+), 389 deletions(-) diff --git a/_scripts/webpack.renderer.config.js b/_scripts/webpack.renderer.config.js index 659f72c09..46f63c9dd 100644 --- a/_scripts/webpack.renderer.config.js +++ b/_scripts/webpack.renderer.config.js @@ -134,13 +134,7 @@ const config = { alias: { vue$: 'vue/dist/vue.common.js', - // use the web version of linkedom - linkedom$: 'linkedom/worker', - - // defaults to the prebundled browser version which causes webpack to error with: - // "Critical dependency: require function is used in a way in which dependencies cannot be statically extracted" - // webpack likes to bundle the dependencies itself, could really have a better error message though - 'youtubei.js$': 'youtubei.js/dist/browser.js', + 'youtubei.js$': 'youtubei.js/web', }, extensions: ['.js', '.vue'] }, diff --git a/_scripts/webpack.web.config.js b/_scripts/webpack.web.config.js index 7fa655404..1c494e5c3 100644 --- a/_scripts/webpack.web.config.js +++ b/_scripts/webpack.web.config.js @@ -22,17 +22,10 @@ const config = { path: path.join(__dirname, '../dist/web'), filename: '[name].js', }, - externals: [ - { - electron: '{}' - }, - ({ request }, callback) => { - if (request.startsWith('youtubei.js')) { - return callback(null, '{}') - } - callback() - } - ], + externals: { + electron: '{}', + 'youtubei.js': '{}' + }, module: { rules: [ { diff --git a/package.json b/package.json index 57ce58f9c..5fc1d46dc 100644 --- a/package.json +++ b/package.json @@ -75,8 +75,7 @@ "vue-observe-visibility": "^1.0.0", "vue-router": "^3.6.5", "vuex": "^3.6.2", - "youtubei.js": "^2.9.0", - "yt-channel-info": "^3.2.1" + "youtubei.js": "^3.1.0" }, "devDependencies": { "@babel/core": "^7.21.0", diff --git a/src/renderer/App.js b/src/renderer/App.js index bfe3f74e8..5686882e2 100644 --- a/src/renderer/App.js +++ b/src/renderer/App.js @@ -430,11 +430,14 @@ export default defineComponent({ } case 'channel': { - const { channelId, subPath } = result + const { channelId, subPath, url } = result openInternalPath({ path: `/channel/${channelId}/${subPath}`, - doCreateNewWindow + doCreateNewWindow, + query: { + url + } }) break } diff --git a/src/renderer/components/data-settings/data-settings.js b/src/renderer/components/data-settings/data-settings.js index 9e7af7cb4..777fe4da9 100644 --- a/src/renderer/components/data-settings/data-settings.js +++ b/src/renderer/components/data-settings/data-settings.js @@ -6,7 +6,6 @@ import FtFlexBox from '../ft-flex-box/ft-flex-box.vue' import FtPrompt from '../ft-prompt/ft-prompt.vue' import { MAIN_PROFILE_ID } from '../../../constants' -import ytch from 'yt-channel-info' import { calculateColorLuminance, getRandomColor } from '../../helpers/colors' import { copyToClipboard, @@ -17,6 +16,7 @@ import { writeFileFromDialog } from '../../helpers/utils' import { invidiousAPICall } from '../../helpers/api/invidious' +import { getLocalChannel } from '../../helpers/api/local' export default defineComponent({ name: 'DataSettings', @@ -967,25 +967,32 @@ export default defineComponent({ }) }, - getChannelInfoLocal: function (channelId) { - return new Promise((resolve, reject) => { - ytch.getChannelInfo({ channelId: channelId }).then(async (response) => { - resolve(response) - }).catch((err) => { - console.error(err) - const errorMessage = this.$t('Local API Error (Click to copy)') - showToast(`${errorMessage}: ${err}`, 10000, () => { - copyToClipboard(err) - }) + getChannelInfoLocal: async function (channelId) { + try { + const channel = await getLocalChannel(channelId) - if (this.backendFallback && this.backendPreference === 'local') { - showToast(this.$t('Falling back to the Invidious API')) - resolve(this.getChannelInfoInvidious(channelId)) - } else { - resolve([]) - } + if (channel.alert) { + return undefined + } + + return { + author: channel.header.author.name, + authorThumbnails: channel.header.author.thumbnails + } + } catch (err) { + console.error(err) + const errorMessage = this.$t('Local API Error (Click to copy)') + showToast(`${errorMessage}: ${err}`, 10000, () => { + copyToClipboard(err) }) - }) + + if (this.backendFallback && this.backendPreference === 'local') { + showToast(this.$t('Falling back to the Invidious API')) + return await this.getChannelInfoInvidious(channelId) + } else { + return [] + } + } }, /* diff --git a/src/renderer/components/top-nav/top-nav.js b/src/renderer/components/top-nav/top-nav.js index a0e003726..87e2e9580 100644 --- a/src/renderer/components/top-nav/top-nav.js +++ b/src/renderer/components/top-nav/top-nav.js @@ -175,12 +175,14 @@ export default defineComponent({ } case 'channel': { - const { channelId, idType, subPath } = result + const { channelId, subPath, url } = result openInternalPath({ path: `/channel/${channelId}/${subPath}`, - query: { idType }, - doCreateNewWindow + doCreateNewWindow, + query: { + url + } }) break } diff --git a/src/renderer/helpers/api/invidious.js b/src/renderer/helpers/api/invidious.js index cf13d9745..784d764d3 100644 --- a/src/renderer/helpers/api/invidious.js +++ b/src/renderer/helpers/api/invidious.js @@ -6,7 +6,7 @@ function getCurrentInstance() { return store.getters.getCurrentInvidiousInstance } -export function invidiousAPICall({ resource, id = '', params = {} }) { +export function invidiousAPICall({ resource, id = '', params = {}, doLogError = true }) { return new Promise((resolve, reject) => { const requestUrl = getCurrentInstance() + '/api/v1/' + resource + '/' + id + '?' + new URLSearchParams(params).toString() @@ -19,12 +19,39 @@ export function invidiousAPICall({ resource, id = '', params = {} }) { resolve(json) }) .catch((error) => { - console.error('Invidious API error', requestUrl, error) + if (doLogError) { + console.error('Invidious API error', requestUrl, error) + } reject(error) }) }) } +/** + * Gets the channel ID for a channel URL + * used to get the ID for channel usernames and handles + * @param {string} url + */ +export async function invidiousGetChannelId(url) { + try { + const response = await invidiousAPICall({ + resource: 'resolveurl', + params: { + url + }, + doLogError: false + }) + + if (response.pageType === 'WEB_PAGE_TYPE_CHANNEL') { + return response.ucid + } else { + return null + } + } catch { + return null + } +} + export async function invidiousGetChannelInfo(channelId) { return await invidiousAPICall({ resource: 'channels', diff --git a/src/renderer/helpers/api/local.js b/src/renderer/helpers/api/local.js index 6906389dc..fb9ad9667 100644 --- a/src/renderer/helpers/api/local.js +++ b/src/renderer/helpers/api/local.js @@ -1,12 +1,10 @@ -import { Innertube } from 'youtubei.js' -import { ClientType } from 'youtubei.js/dist/src/core/Session' -import EmojiRun from 'youtubei.js/dist/src/parser/classes/misc/EmojiRun' -import Text from 'youtubei.js/dist/src/parser/classes/misc/Text' +import { Innertube, ClientType, Misc, Utils } from 'youtubei.js' import Autolinker from 'autolinker' import { join } from 'path' import { PlayerCache } from './PlayerCache' import { + CHANNEL_HANDLE_REGEX, extractNumberFromString, getUserDataPath, toLocalePublicationString @@ -88,7 +86,7 @@ export async function getLocalTrending(location, tab, instance) { const results = resultsInstance.videos .filter((video) => video.type === 'Video') - .map(parseListVideo) + .map(parseLocalListVideo) return { results, @@ -166,6 +164,117 @@ function decipherFormats(formats, player) { } } +export async function getLocalChannelId(url) { + try { + const innertube = await createInnertube() + + // resolveURL throws an error if the URL doesn't exist + const navigationEndpoint = await innertube.resolveURL(url) + + if (navigationEndpoint.metadata.page_type === 'WEB_PAGE_TYPE_CHANNEL') { + return navigationEndpoint.payload.browseId + } else { + return null + } + } catch { + return null + } +} + +/** + * Returns the channel or the channel termination reason + * @param {string} id + */ +export async function getLocalChannel(id) { + const innertube = await createInnertube() + let result + try { + result = await innertube.getChannel(id) + } catch (error) { + if (error instanceof Utils.ChannelError) { + result = { + alert: error.message + } + } else { + throw error + } + } + return result +} + +export async function getLocalChannelVideos(id) { + const channel = await getLocalChannel(id) + + if (channel.alert) { + return null + } + + if (!channel.has_videos) { + return [] + } + + const videosTab = await channel.getVideos() + + return parseLocalChannelVideos(videosTab.videos, channel.header.author) +} + +/** + * @param {import('youtubei.js/dist/src/parser/classes/Video').default[]} videos + * @param {import('youtubei.js/dist/src/parser/classes/misc/Author').default} author + */ +export function parseLocalChannelVideos(videos, author) { + const parsedVideos = videos.map(parseLocalListVideo) + + // fix empty author info + parsedVideos.forEach(video => { + video.author = author.name + video.authorId = author.id + }) + + return parsedVideos +} + +/** + * @typedef {import('youtubei.js/dist/src/parser/classes/Playlist').default} Playlist + * @typedef {import('youtubei.js/dist/src/parser/classes/GridPlaylist').default} GridPlaylist + */ + +/** + * @param {Playlist|GridPlaylist} playlist + * @param {import('youtubei.js/dist/src/parser/classes/misc/Author').default} author + */ +export function parseLocalListPlaylist(playlist, author = undefined) { + let channelName + let channelId = null + + if (playlist.author) { + if (playlist.author instanceof Misc.Text) { + channelName = playlist.author.text + + if (author) { + channelId = author.id + } + } else { + channelName = playlist.author.name + channelId = playlist.author.id + } + } else { + channelName = author.name + channelId = author.id + } + + return { + type: 'playlist', + dataSource: 'local', + title: playlist.title.text, + thumbnail: playlist.thumbnails[0].url, + channelName, + channelId, + playlistId: playlist.id, + videoCount: extractNumberFromString(playlist.video_count.text) + } +} + /** * @param {Search} response */ @@ -207,13 +316,9 @@ export function parseLocalPlaylistVideo(video) { } /** - * @typedef {import('youtubei.js/dist/src/parser/classes/Video').default} Video + * @param {import('youtubei.js/dist/src/parser/classes/Video').default} video */ - -/** - * @param {Video} video - */ -function parseListVideo(video) { +export function parseLocalListVideo(video) { return { type: 'video', videoId: video.id, @@ -231,20 +336,14 @@ function parseListVideo(video) { } /** - * @typedef {import('youtubei.js/dist/src/parser/helpers').YTNode} YTNode - * @typedef {import('youtubei.js/dist/src/parser/classes/Channel').default} Channel - * @typedef {import('youtubei.js/dist/src/parser/classes/Playlist').default} Playlist - */ - -/** - * @param {YTNode} item + * @param {import('youtubei.js/dist/src/parser/helpers').YTNode} item */ function parseListItem(item) { switch (item.type) { case 'Video': - return parseListVideo(item) + return parseLocalListVideo(item) case 'Channel': { - /** @type {Channel} */ + /** @type {import('youtubei.js/dist/src/parser/classes/Channel').default} */ const channel = item // see upstream TODO: https://github.com/LuanRT/YouTube.js/blob/main/src/parser/classes/Channel.ts#L33 @@ -281,29 +380,7 @@ function parseListItem(item) { } } case 'Playlist': { - /** @type {Playlist} */ - const playlist = item - - let channelName - let channelId = null - - if (playlist.author instanceof Text) { - channelName = playlist.author.text - } else { - channelName = playlist.author.name - channelId = playlist.author.id - } - - return { - type: 'playlist', - dataSource: 'local', - title: playlist.title, - thumbnail: playlist.thumbnails[0].url, - channelName, - channelId, - playlistId: playlist.id, - videoCount: extractNumberFromString(playlist.video_count.text) - } + return parseLocalListPlaylist(item) } } } @@ -359,6 +436,7 @@ function convertSearchFilters(filters) { /** * @typedef {import('youtubei.js/dist/src/parser/classes/misc/TextRun').default} TextRun + * @typedef {import('youtubei.js/dist/src/parser/classes/misc/EmojiRun').default} EmojiRun */ /** @@ -374,7 +452,7 @@ export function parseLocalTextRuns(runs, emojiSize = 16) { const parsedRuns = [] for (const run of runs) { - if (run instanceof EmojiRun) { + if (run instanceof Misc.EmojiRun) { const { emoji, text } = run // empty array if video creator removes a channel emoji so we ignore. @@ -413,7 +491,7 @@ export function parseLocalTextRuns(runs, emojiSize = 16) { break case 'WEB_PAGE_TYPE_CHANNEL': { const trimmedText = text.trim() - if (trimmedText.startsWith('@')) { + if (CHANNEL_HANDLE_REGEX.test(trimmedText)) { parsedRuns.push(`${trimmedText}`) } else { parsedRuns.push(`https://www.youtube.com${endpoint.metadata.url}`) @@ -548,3 +626,32 @@ export function filterFormats(formats, allowAv1 = false) { return [...audioFormats, ...h264Formats] } } + +/** + * Really not a fan of this :(, YouTube returns the subscribers as "15.1M subscribers" + * so we have to parse it somehow + * @param {string} text + */ +export function parseLocalSubscriberCount(text) { + const match = text + .replace(',', '.') + .toUpperCase() + .match(/([\d.]+)\s*([KM]?)/) + + let subscribers + if (match) { + subscribers = parseFloat(match[1]) + + if (match[2] === 'K') { + subscribers *= 1000 + } else if (match[2] === 'M') { + subscribers *= 1000_000 + } + + subscribers = Math.trunc(subscribers) + } else { + subscribers = extractNumberFromString(text) + } + + return subscribers +} diff --git a/src/renderer/helpers/utils.js b/src/renderer/helpers/utils.js index e8254b67e..43f6ef5bc 100644 --- a/src/renderer/helpers/utils.js +++ b/src/renderer/helpers/utils.js @@ -5,6 +5,10 @@ import FtToastEvents from '../components/ft-toast/ft-toast-events' import i18n from '../i18n/index' import router from '../router/index' +// allowed characters in channel handle: A-Z, a-z, 0-9, -, _, . +// https://support.google.com/youtube/answer/11585688#change_handle +export const CHANNEL_HANDLE_REGEX = /^@[\w.-]{3,30}$/ + export function calculatePublishedDate(publishedText) { const date = new Date() if (publishedText === 'Live') { diff --git a/src/renderer/main.js b/src/renderer/main.js index dd153e5fd..2aedf2dc6 100644 --- a/src/renderer/main.js +++ b/src/renderer/main.js @@ -14,6 +14,7 @@ import { faBookmark, faCheck, faChevronRight, + faCircleUser, faClone, faCommentDots, faCopy, @@ -77,6 +78,7 @@ library.add( faBookmark, faCheck, faChevronRight, + faCircleUser, faClone, faCommentDots, faCopy, diff --git a/src/renderer/store/modules/utils.js b/src/renderer/store/modules/utils.js index 81d7e49fb..97e062b4e 100644 --- a/src/renderer/store/modules/utils.js +++ b/src/renderer/store/modules/utils.js @@ -5,6 +5,7 @@ import i18n from '../../i18n/index' import { IpcChannels } from '../../../constants' import { pathExists } from '../../helpers/filesystem' import { + CHANNEL_HANDLE_REGEX, createWebURL, getVideoParamsFromUrl, openExternalLink, @@ -261,7 +262,7 @@ const actions = { commit('setRegionValues', regionValues) }, - getYoutubeUrlInfo ({ state }, urlStr) { + async getYoutubeUrlInfo({ rootState, state }, urlStr) { // Returns // - urlType [String] `video`, `playlist` // @@ -288,6 +289,11 @@ const actions = { // // If `urlType` is "invalid_url" // Nothing else + + if (CHANNEL_HANDLE_REGEX.test(urlStr)) { + urlStr = `https://www.youtube.com/${urlStr}` + } + const { videoId, timestamp, playlistId } = getVideoParamsFromUrl(urlStr) if (videoId) { return { @@ -309,7 +315,7 @@ const actions = { let urlType = 'unknown' const channelPattern = - /^\/(?:(?channel|user|c)\/)?(?[^/]+)(?:\/(join|featured|videos|playlists|about|community|channels))?\/?$/ + /^\/(?:(?:channel|user|c)\/)?(?[^/]+)(?:\/(join|featured|videos|playlists|about|community|channels))?\/?$/ const typePatterns = new Map([ ['playlist', /^(\/playlist\/?|\/embed(\/?videoseries)?)$/], @@ -409,7 +415,6 @@ const actions = { case 'channel': { const match = url.pathname.match(channelPattern) const channelId = match.groups.channelId - const idType = ['channel', 'user', 'c'].indexOf(match.groups.type) + 1 if (!channelId) { throw new Error('Channel: could not extract id') } @@ -431,8 +436,8 @@ const actions = { return { urlType: 'channel', channelId, - idType, - subPath + subPath, + url: url.toString() } } diff --git a/src/renderer/views/Channel/Channel.css b/src/renderer/views/Channel/Channel.css index 30e4751a6..ffaa72ab5 100644 --- a/src/renderer/views/Channel/Channel.css +++ b/src/renderer/views/Channel/Channel.css @@ -18,6 +18,7 @@ } .channelBannerContainer.default { + background-color: black; background-image: url("../../assets/img/defaultBanner.png"); background-repeat: repeat; background-size: contain; @@ -37,11 +38,16 @@ justify-content: space-between; } +.channelInfoHasError { + padding-bottom: 10px; +} + .channelThumbnail { width: 100px; height: 100px; border-radius: 200px 200px 200px 200px; -webkit-border-radius: 200px 200px 200px 200px; + object-fit: cover; } .channelName { @@ -58,7 +64,7 @@ .channelInfoActionsContainer { display: flex; - min-width: 230px; + gap: 30px; justify-content: space-between; } @@ -137,6 +143,36 @@ white-space: pre-wrap; } +.aboutTags { + display: flex; + flex-flow: row wrap; + gap: 5px 15px; + justify-content: center; + margin: 0; + padding: 0; +} + +.aboutTag { + display: flex; + list-style: none; +} + +.aboutTagLink { + background-color: var(--secondary-card-bg-color); + border-radius: 7px; + color: inherit; + padding: 7px; + text-decoration: none; +} + +.aboutDetails { + text-align: left; +} + +.aboutDetails th { + padding-right: 10px; +} + .channelSearch { margin-top: 10px; max-width: 250px; diff --git a/src/renderer/views/Channel/Channel.js b/src/renderer/views/Channel/Channel.js index 778b83bfb..e94934dc8 100644 --- a/src/renderer/views/Channel/Channel.js +++ b/src/renderer/views/Channel/Channel.js @@ -11,15 +11,27 @@ import FtElementList from '../../components/ft-element-list/ft-element-list.vue' import FtAgeRestricted from '../../components/ft-age-restricted/ft-age-restricted.vue' import FtShareButton from '../../components/ft-share-button/ft-share-button.vue' -import ytch from 'yt-channel-info' import autolinker from 'autolinker' import { MAIN_PROFILE_ID } from '../../../constants' -import { copyToClipboard, formatNumber, showToast } from '../../helpers/utils' +import { copyToClipboard, extractNumberFromString, formatNumber, showToast } from '../../helpers/utils' import packageDetails from '../../../../package.json' -import { invidiousAPICall, invidiousGetChannelInfo, youtubeImageUrlToInvidious } from '../../helpers/api/invidious' +import { + invidiousAPICall, + invidiousGetChannelId, + invidiousGetChannelInfo, + youtubeImageUrlToInvidious +} from '../../helpers/api/invidious' +import { + getLocalChannel, + getLocalChannelId, + parseLocalChannelVideos, + parseLocalListPlaylist, + parseLocalListVideo, + parseLocalSubscriberCount +} from '../../helpers/api/local' export default defineComponent({ - name: 'Search', + name: 'Channel', components: { 'ft-card': FtCard, 'ft-button': FtButton, @@ -38,18 +50,22 @@ export default defineComponent({ isElementListLoading: false, currentTab: 'videos', id: '', - idType: 0, + channelInstance: null, channelName: '', bannerUrl: '', thumbnailUrl: '', subCount: 0, searchPage: 2, - videoContinuationString: '', - playlistContinuationString: '', - searchContinuationString: '', - channelDescription: '', + videoContinuationData: null, + playlistContinuationData: null, + searchContinuationData: null, + description: '', + tags: [], + views: 0, + joined: 0, + location: null, videoSortBy: 'newest', - playlistSortBy: 'last', + playlistSortBy: 'newest', lastSearchQuery: '', relatedChannels: [], latestVideos: [], @@ -59,14 +75,15 @@ export default defineComponent({ apiUsed: '', isFamilyFriendly: false, errorMessage: '', + showSearchBar: true, + showShareMenu: true, videoSelectValues: [ 'newest', - 'oldest', 'popular' ], playlistSelectValues: [ - 'last', - 'newest' + 'newest', + 'last' ], tabInfoValues: [ 'videos', @@ -100,6 +117,10 @@ export default defineComponent({ return this.$store.getters.getSessionSearchHistory }, + currentLocale: function () { + return this.$i18n.locale.replace('_', '-') + }, + profileList: function () { return this.$store.getters.getProfileList }, @@ -129,15 +150,14 @@ export default defineComponent({ videoSelectNames: function () { return [ this.$t('Channel.Videos.Sort Types.Newest'), - this.$t('Channel.Videos.Sort Types.Oldest'), this.$t('Channel.Videos.Sort Types.Most Popular') ] }, playlistSelectNames: function () { return [ - this.$t('Channel.Playlists.Sort Types.Last Video Added'), - this.$t('Channel.Playlists.Sort Types.Newest') + this.$t('Channel.Playlists.Sort Types.Newest'), + this.$t('Channel.Playlists.Sort Types.Last Video Added') ] }, @@ -148,20 +168,28 @@ export default defineComponent({ return formatNumber(this.subCount) }, + formattedViews: function () { + return formatNumber(this.views) + }, + + formattedJoined: function () { + return new Intl.DateTimeFormat([this.currentLocale, 'en'], { dateStyle: 'long' }).format(this.joined) + }, + showFetchMoreButton: function () { switch (this.currentTab) { case 'videos': - if (this.apiUsed === 'invidious' || (this.videoContinuationString !== '' && this.videoContinuationString !== null)) { + if (this.videoContinuationData !== null) { return true } break case 'playlists': - if (this.playlistContinuationString !== '' && this.playlistContinuationString !== null) { + if (this.playlistContinuationData !== null) { return true } break case 'search': - if (this.searchContinuationString !== '' && this.searchContinuationString !== null) { + if (this.searchContinuationData !== null) { return true } break @@ -171,14 +199,31 @@ export default defineComponent({ }, hideChannelSubscriptions: function () { return this.$store.getters.getHideChannelSubscriptions + }, + + searchSettings: function () { + return this.$store.getters.getSearchSettings + }, + + hideSearchBar: function () { + return this.$store.getters.getHideSearchBar + }, + + hideSharingActions: function () { + return this.$store.getters.getHideSharingActions } }, watch: { $route() { // react to route changes... - this.originalId = this.$route.params.id + this.isLoading = true + + if (this.$route.query.url) { + this.resolveChannelUrl(this.$route.query.url, this.$route.params.currentTab) + return + } + this.id = this.$route.params.id - this.idType = this.$route.query.idType ? Number(this.$route.query.idType) : 0 this.currentTab = this.$route.params.currentTab ?? 'videos' this.searchPage = 2 this.relatedChannels = [] @@ -187,15 +232,25 @@ export default defineComponent({ this.searchResults = [] this.shownElementList = [] this.apiUsed = '' - this.isLoading = true + this.channelInstance = '' + this.videoContinuationData = null + this.playlistContinuationData = null + this.searchContinuationData = null + this.showSearchBar = true + + if (this.id === '@@@') { + this.showShareMenu = false + this.setErrorMessage(this.$i18n.t('Channel.This channel does not exist')) + return + } + + this.showShareMenu = true + this.errorMessage = '' if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') { this.getChannelInfoInvidious() - this.getPlaylistsInvidious() } else { - this.getChannelInfoLocal() - this.getChannelVideosLocal() - this.getPlaylistsLocal() + this.getChannelLocal() } }, @@ -207,7 +262,7 @@ export default defineComponent({ this.getChannelVideosLocal() break case 'invidious': - this.channelInvidiousVideos() + this.channelInvidiousVideos(true) break default: this.getChannelVideosLocal() @@ -217,95 +272,247 @@ export default defineComponent({ playlistSortBy () { this.isElementListLoading = true this.latestPlaylists = [] - this.playlistContinuationString = '' + this.playlistContinuationData = null switch (this.apiUsed) { case 'local': - this.getPlaylistsLocal() + this.getChannelPlaylistsLocal() break case 'invidious': this.getPlaylistsInvidious() break default: - this.getPlaylistsLocal() + this.getChannelPlaylistsLocal() } } }, mounted: function () { - this.originalId = this.$route.params.id - this.id = this.$route.params.id - this.idType = this.$route.query.idType ? Number(this.$route.query.idType) : 0 - this.currentTab = this.$route.params.currentTab ?? 'videos' this.isLoading = true + if (this.$route.query.url) { + this.resolveChannelUrl(this.$route.query.url, this.$route.params.currentTab) + return + } + + this.id = this.$route.params.id + this.currentTab = this.$route.params.currentTab ?? 'videos' + + if (this.id === '@@@') { + this.showShareMenu = false + this.setErrorMessage(this.$i18n.t('Channel.This channel does not exist')) + return + } + if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') { this.getChannelInfoInvidious() - this.getPlaylistsInvidious() } else { - this.getChannelInfoLocal() - this.getChannelVideosLocal() - this.getPlaylistsLocal() + this.getChannelLocal() } }, methods: { + resolveChannelUrl: async function (url, tab = undefined) { + let id + + if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') { + id = await invidiousGetChannelId(url) + } else { + id = await getLocalChannelId(url) + } + + if (id === null) { + // the channel page shows an error about the channel not existing when the id is @@@ + id = '@@@' + } + + // use router.replace to replace the current history entry + // with the one with the resolved channel id + // that way if you navigate back or forward in the history to this entry + // we don't need to resolve the URL again as we already know it + if (tab) { + this.$router.replace({ path: `/channel/${id}/${tab}` }) + } else { + this.$router.replace({ path: `/channel/${id}` }) + } + }, + goToChannel: function (id) { this.$router.push({ path: `/channel/${id}` }) }, - getChannelInfoLocal: function () { + getChannelLocal: async function () { this.apiUsed = 'local' - const expectedId = this.originalId - ytch.getChannelInfo({ channelId: this.id, channelIdType: this.idType }).then((response) => { - if (response.alertMessage) { - this.setErrorMessage(response.alertMessage) + this.isLoading = true + const expectedId = this.id + + try { + const channel = await getLocalChannel(this.id) + + let channelName + let channelThumbnailUrl + + if (channel.alert) { + this.setErrorMessage(channel.alert) + return + } else if (channel.memo.has('ChannelAgeGate')) { + /** @type {import('youtubei.js/dist/src/parser/classes/ChannelAgeGate').default} */ + const ageGate = channel.memo.get('ChannelAgeGate')[0] + + channelName = ageGate.channel_title + channelThumbnailUrl = ageGate.avatar[0].url + + this.channelName = channelName + this.thumbnailUrl = channelThumbnailUrl + + document.title = `${channelName} - ${packageDetails.productName}` + + this.updateSubscriptionDetails({ channelThumbnailUrl, channelName, channelId: this.id }) + + this.setErrorMessage(this.$t('Channel["This channel is age resticted and currently cannot be viewed in FreeTube."]'), true) return } + this.errorMessage = '' - if (expectedId !== this.originalId) { + if (expectedId !== this.id) { return } - const channelId = response.authorId - const channelName = response.author - const channelThumbnailUrl = response.authorThumbnails[2].url - this.id = channelId - // set the id type to 1 so that searching and sorting work - this.idType = 1 - this.channelName = channelName - this.isFamilyFriendly = response.isFamilyFriendly - document.title = `${this.channelName} - ${packageDetails.productName}` - if (this.hideChannelSubscriptions || response.subscriberCount === 0) { - this.subCount = null - } else { - this.subCount = response.subscriberCount.toFixed(0) - } - this.thumbnailUrl = channelThumbnailUrl - this.updateSubscriptionDetails({ channelThumbnailUrl, channelName, channelId }) - this.channelDescription = autolinker.link(response.description) - this.relatedChannels = response.relatedChannels.items - this.relatedChannels.forEach(relatedChannel => { - relatedChannel.thumbnail.map(thumbnail => { - if (!thumbnail.url.includes('https')) { - thumbnail.url = `https:${thumbnail.url}` - } - return thumbnail - }) - relatedChannel.authorThumbnails = relatedChannel.thumbnail - }) + let channelId + let subscriberText = null + let tags = [] - if (response.authorBanners !== null) { - const bannerUrl = response.authorBanners[response.authorBanners.length - 1].url + switch (channel.header.type) { + case 'C4TabbedHeader': { + // example: Linus Tech Tips + // https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw - if (!bannerUrl.includes('https')) { - this.bannerUrl = `https://${bannerUrl}` - } else { - this.bannerUrl = bannerUrl + /** + * @type {import('youtubei.js/dist/src/parser/classes/C4TabbedHeader').default} + */ + const header = channel.header + + 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/dist/src/parser/classes/CarouselHeader').default} + */ + const header = channel.header + + /** + * @type {import('youtubei.js/dist/src/parser/classes/TopicChannelDetails').default} + */ + 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/dist/src/parser/classes/InteractiveTabbedHeader').default} + */ + 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 + } + } + + this.channelName = channelName + this.thumbnailUrl = channelThumbnailUrl + this.isFamilyFriendly = !!channel.metadata.is_family_safe + + if (channel.metadata.tags) { + tags.push(...channel.metadata.tags) + } + + // deduplicate tags + // a Set can only ever contain unique elements, + // so this is an easy way to get rid of duplicates + if (tags.length > 0) { + tags = Array.from(new Set(tags)) + } + this.tags = tags + + document.title = `${channelName} - ${packageDetails.productName}` + + if (!this.hideChannelSubscriptions && subscriberText) { + const subCount = parseLocalSubscriberCount(subscriberText) + + if (isNaN(subCount)) { + this.subCount = null + } else { + this.subCount = subCount + } + } else { + this.subCount = null + } + + 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 + + if (thumbnailUrl.startsWith('//')) { + thumbnailUrl = `https:${thumbnailUrl}` + } + + return { + name: author.name, + id: author.id, + thumbnailUrl + } + }) + + this.channelInstance = channel + + if (channel.has_about) { + this.getChannelAboutLocal() + } else { + this.description = '' + this.views = null + this.joined = 0 + this.location = null + } + + if (channel.has_videos) { + this.getChannelVideosLocal() + } + + if (channel.has_playlists) { + this.getChannelPlaylistsLocal() + } + + this.showSearchBar = channel.has_search + this.isLoading = false - }).catch((err) => { + } catch (err) { console.error(err) const errorMessage = this.$t('Local API Error (Click to copy)') showToast(`${errorMessage}: ${err}`, 10000, () => { @@ -317,21 +524,64 @@ export default defineComponent({ } else { this.isLoading = false } - }) + } }, - getChannelVideosLocal: function () { + getChannelAboutLocal: async function () { + try { + /** + * @type {import('youtubei.js/dist/src/parser/youtube/Channel').default} + */ + const channel = this.channelInstance + const about = await channel.getAbout() + + this.description = about.description.text !== 'N/A' ? autolinker.link(about.description.text) : '' + + const views = extractNumberFromString(about.views.text) + this.views = isNaN(views) ? null : views + + this.joined = new Date(about.joined.text.replace('Joined').trim()) + + this.location = about.country.text !== 'N/A' ? about.country.text : null + } catch (err) { + console.error(err) + const errorMessage = this.$t('Local API Error (Click to copy)') + showToast(`${errorMessage}: ${err}`, 10000, () => { + copyToClipboard(err) + }) + if (this.backendPreference === 'local' && this.backendFallback) { + showToast(this.$t('Falling back to Invidious API')) + this.getChannelInfoInvidious() + } else { + this.isLoading = false + } + } + }, + + getChannelVideosLocal: async function () { this.isElementListLoading = true - const expectedId = this.originalId - ytch.getChannelVideos({ channelId: this.id, channelIdType: this.idType, sortBy: this.videoSortBy }).then((response) => { - if (expectedId !== this.originalId) { + const expectedId = this.id + + try { + /** + * @type {import('youtubei.js/dist/src/parser/youtube/Channel').default} + */ + const channel = this.channelInstance + let videosTab = await channel.getVideos() + + if (this.videoSortBy !== 'newest') { + const index = this.videoSelectValues.indexOf(this.videoSortBy) + videosTab = await videosTab.applyFilter(videosTab.filters[index]) + } + + if (expectedId !== this.id) { return } - this.latestVideos = response.items - this.videoContinuationString = response.continuation + this.latestVideos = parseLocalChannelVideos(videosTab.videos, channel.header.author) + this.videoContinuationData = videosTab.has_continuation ? videosTab : null this.isElementListLoading = false - }).catch((err) => { + } catch (err) { console.error(err) const errorMessage = this.$t('Local API Error (Click to copy)') showToast(`${errorMessage}: ${err}`, 10000, () => { @@ -343,29 +593,35 @@ export default defineComponent({ } else { this.isLoading = false } - }) + } }, - channelLocalNextPage: function () { - ytch.getChannelVideosMore({ continuation: this.videoContinuationString }).then((response) => { - this.latestVideos = this.latestVideos.concat(response.items) - this.videoContinuationString = response.continuation - }).catch((err) => { + channelLocalNextPage: async function () { + try { + /** + * @type {import('youtubei.js/dist/src/parser/youtube/Channel').ChannelListContinuation|import('youtubei.js/dist/src/parser/youtube/Channel').FilteredChannelList} + */ + const continuation = await this.videoContinuationData.getContinuation() + + this.latestVideos = this.latestVideos.concat(parseLocalChannelVideos(continuation.videos, this.channelInstance.header.author)) + this.videoContinuationData = continuation.has_continuation ? continuation : null + } catch (err) { console.error(err) const errorMessage = this.$t('Local API Error (Click to copy)') showToast(`${errorMessage}: ${err}`, 10000, () => { copyToClipboard(err) }) - }) + } }, getChannelInfoInvidious: function () { this.isLoading = true this.apiUsed = 'invidious' + this.channelInstance = null - const expectedId = this.originalId + const expectedId = this.id invidiousGetChannelInfo(this.id).then((response) => { - if (expectedId !== this.originalId) { + if (expectedId !== this.id) { return } @@ -383,14 +639,16 @@ export default defineComponent({ const thumbnail = response.authorThumbnails[3].url this.thumbnailUrl = youtubeImageUrlToInvidious(thumbnail, this.currentInvidiousInstance) this.updateSubscriptionDetails({ channelThumbnailUrl: thumbnail, channelName: channelName, channelId: channelId }) - this.channelDescription = autolinker.link(response.description) + this.description = autolinker.link(response.description) + this.views = response.totalViews + this.joined = new Date(response.joined * 1000) this.relatedChannels = response.relatedChannels.map((channel) => { - channel.authorThumbnails = channel.authorThumbnails.map(thumbnail => { - thumbnail.url = youtubeImageUrlToInvidious(thumbnail.url, this.currentInvidiousInstance) - return thumbnail - }) - channel.channelId = channel.authorId - return channel + const thumbnailUrl = channel.authorThumbnails.at(-1).url + return { + name: channel.author, + id: channel.authorId, + thumbnailUrl: youtubeImageUrlToInvidious(thumbnailUrl, this.currentInvidiousInstance) + } }) this.latestVideos = response.latestVideos @@ -401,19 +659,36 @@ export default defineComponent({ } this.errorMessage = '' + + // some channels only have a few tabs + // here are all possible values: home, videos, shorts, streams, playlists, community, channels, about + + if (response.tabs.includes('videos')) { + this.channelInvidiousVideos() + } + + if (response.tabs.includes('playlists')) { + this.getPlaylistsInvidious() + } + this.isLoading = false }).catch((err) => { - this.setErrorMessage(err.responseJSON.error) + this.setErrorMessage(err) console.error(err) const errorMessage = this.$t('Invidious API Error (Click to copy)') - showToast(`${errorMessage}: ${err.responseJSON.error}`, 10000, () => { - copyToClipboard(err.responseJSON.error) + showToast(`${errorMessage}: ${err}`, 10000, () => { + copyToClipboard(err) }) - this.isLoading = false + if (process.env.IS_ELECTRON && this.backendPreference === 'invidious' && this.backendFallback) { + showToast(this.$t('Falling back to Local API')) + this.getChannelLocal() + } else { + this.isLoading = false + } }) }, - channelInvidiousVideos: function (fetchMore) { + channelInvidiousVideos: function (sortByChanged) { const payload = { resource: 'channels/videos', id: this.id, @@ -421,11 +696,28 @@ export default defineComponent({ sort_by: this.videoSortBy, } } - if (fetchMore) payload.params.continuation = this.videoContinuationString + + if (sortByChanged) { + this.videoContinuationData = null + } + + let more = false + if (this.videoContinuationData) { + payload.params.continuation = this.videoContinuationData + more = true + } + + if (!more) { + this.isElementListLoading = true + } invidiousAPICall(payload).then((response) => { - this.latestVideos = this.latestVideos.concat(response.videos) - this.videoContinuationString = response.continuation + if (more) { + this.latestVideos = this.latestVideos.concat(response.videos) + } else { + this.latestVideos = response.videos + } + this.videoContinuationData = response.continuation || null this.isElementListLoading = false }).catch((err) => { console.error(err) @@ -436,20 +728,45 @@ export default defineComponent({ }) }, - getPlaylistsLocal: function () { - const expectedId = this.originalId - ytch.getChannelPlaylistInfo({ channelId: this.id, channelIdType: this.idType, sortBy: this.playlistSortBy }).then((response) => { - if (expectedId !== this.originalId) { + getChannelPlaylistsLocal: async function () { + const expectedId = this.id + + try { + /** + * @type {import('youtubei.js/dist/src/parser/youtube/Channel').default} + */ + const channel = this.channelInstance + let playlistsTab = await channel.getPlaylists() + + // some channels have more categories of playlists than just "Created Playlists" e.g. https://www.youtube.com/channel/UCez-2shYlHQY3LfILBuDYqQ + // for the moment we just want the "Created Playlists" category that has all playlists in it + + if (playlistsTab.content_type_filters.length > 1) { + /** + * @type {import('youtubei.js/dist/src/parser/classes/ChannelSubMenu').default} + */ + const menu = playlistsTab.current_tab.content.sub_menu + const createdPlaylistsFilter = menu.content_type_sub_menu_items.find(contentType => { + const url = `https://youtube.com/${contentType.endpoint.metadata.url}` + return new URL(url).searchParams.get('view') === '1' + }).title + + playlistsTab = await playlistsTab.applyContentTypeFilter(createdPlaylistsFilter) + } + + if (this.playlistSortBy !== 'newest' && playlistsTab.sort_filters.length > 0) { + const index = this.playlistSelectValues.indexOf(this.playlistSortBy) + playlistsTab = await playlistsTab.applySort(playlistsTab.sort_filters[index]) + } + + if (expectedId !== this.id) { return } - this.latestPlaylists = response.items.map((item) => { - item.proxyThumbnail = false - return item - }) - this.playlistContinuationString = response.continuation + this.latestPlaylists = playlistsTab.playlists.map(playlist => parseLocalListPlaylist(playlist, channel.header.author)) + this.playlistContinuationData = playlistsTab.has_continuation ? playlistsTab : null this.isElementListLoading = false - }).catch((err) => { + } catch (err) { console.error(err) const errorMessage = this.$t('Local API Error (Click to copy)') showToast(`${errorMessage}: ${err}`, 10000, () => { @@ -461,23 +778,30 @@ export default defineComponent({ } else { this.isLoading = false } - }) + } }, - getPlaylistsLocalMore: function () { - ytch.getChannelPlaylistsMore({ continuation: this.playlistContinuationString }).then((response) => { - this.latestPlaylists = this.latestPlaylists.concat(response.items) - this.playlistContinuationString = response.continuation - }).catch((err) => { + getChannelPlaylistsLocalMore: async function () { + try { + /** + * @type {import('youtubei.js/dist/src/parser/youtube/Channel').ChannelListContinuation} + */ + const continuation = await this.playlistContinuationData.getContinuation() + + const parsedPlaylists = continuation.playlists.map(playlist => parseLocalListPlaylist(playlist, this.channelInstance.header.author)) + this.latestPlaylists = this.latestPlaylists.concat(parsedPlaylists) + this.playlistContinuationData = continuation.has_continuation ? continuation : null + } catch (err) { console.error(err) const errorMessage = this.$t('Local API Error (Click to copy)') showToast(`${errorMessage}: ${err}`, 10000, () => { copyToClipboard(err) }) - }) + } }, getPlaylistsInvidious: function () { + this.isElementListLoading = true const payload = { resource: 'channels/playlists', id: this.id, @@ -487,18 +811,18 @@ export default defineComponent({ } invidiousAPICall(payload).then((response) => { - this.playlistContinuationString = response.continuation + this.playlistContinuationData = response.continuation || null this.latestPlaylists = response.playlists this.isElementListLoading = false }).catch((err) => { console.error(err) const errorMessage = this.$t('Invidious API Error (Click to copy)') - showToast(`${errorMessage}: ${err.responseJSON.error}`, 10000, () => { - copyToClipboard(err.responseJSON.error) + showToast(`${errorMessage}: ${err}`, 10000, () => { + copyToClipboard(err) }) if (process.env.IS_ELECTRON && this.backendPreference === 'invidious' && this.backendFallback) { showToast(this.$t('Falling back to Local API')) - this.getPlaylistsLocal() + this.getChannelLocal() } else { this.isLoading = false } @@ -506,7 +830,7 @@ export default defineComponent({ }, getPlaylistsInvidiousMore: function () { - if (this.playlistContinuationString === null) { + if (this.playlistContinuationData === null) { console.warn('There are no more playlists available for this channel') return } @@ -519,23 +843,23 @@ export default defineComponent({ } } - if (this.playlistContinuationString) { - payload.params.continuation = this.playlistContinuationString + if (this.playlistContinuationData) { + payload.params.continuation = this.playlistContinuationData } invidiousAPICall(payload).then((response) => { - this.playlistContinuationString = response.continuation + this.playlistContinuationData = response.continuation || null this.latestPlaylists = this.latestPlaylists.concat(response.playlists) this.isElementListLoading = false }).catch((err) => { console.error(err) const errorMessage = this.$t('Invidious API Error (Click to copy)') - showToast(`${errorMessage}: ${err.responseJSON.error}`, 10000, () => { - copyToClipboard(err.responseJSON.error) + showToast(`${errorMessage}: ${err}`, 10000, () => { + copyToClipboard(err) }) if (process.env.IS_ELECTRON && this.backendPreference === 'invidious' && this.backendFallback) { showToast(this.$t('Falling back to Local API')) - this.getPlaylistsLocal() + this.getChannelLocal() } else { this.isLoading = false } @@ -608,12 +932,14 @@ export default defineComponent({ } }, - setErrorMessage: function (errorMessage) { + setErrorMessage: function (errorMessage, responseHasNameAndThumbnail = false) { this.isLoading = false this.errorMessage = errorMessage - this.id = this.subscriptionInfo.id - this.channelName = this.subscriptionInfo.name - this.thumbnailUrl = this.subscriptionInfo.thumbnail + + if (!responseHasNameAndThumbnail) { + this.channelName = this.subscriptionInfo?.name + this.thumbnailUrl = this.subscriptionInfo?.thumbnail + } this.bannerUrl = null this.subCount = null }, @@ -626,14 +952,14 @@ export default defineComponent({ this.channelLocalNextPage() break case 'invidious': - this.channelInvidiousVideos(true) + this.channelInvidiousVideos() break } break case 'playlists': switch (this.apiUsed) { case 'local': - this.getPlaylistsLocalMore() + this.getChannelPlaylistsLocalMore() break case 'invidious': this.getPlaylistsInvidiousMore() @@ -687,7 +1013,7 @@ export default defineComponent({ newSearch: function (query) { this.lastSearchQuery = query - this.searchContinuationString = '' + this.searchContinuationData = null this.isElementListLoading = true this.searchPage = 1 this.searchResults = [] @@ -702,37 +1028,58 @@ export default defineComponent({ } }, - searchChannelLocal: function () { - if (this.searchContinuationString === '') { - ytch.searchChannel({ channelId: this.id, channelIdType: this.idType, query: this.lastSearchQuery }).then((response) => { - this.searchResults = response.items - this.isElementListLoading = false - this.searchContinuationString = response.continuation - }).catch((err) => { - console.error(err) - const errorMessage = this.$t('Local API Error (Click to copy)') - showToast(`${errorMessage}: ${err}`, 10000, () => { - copyToClipboard(err) + searchChannelLocal: async function () { + const isNewSearch = this.searchContinuationData === null + try { + let result + let contents + if (isNewSearch) { + if (!this.channelInstance.has_search) { + showToast(this.$t('Channel.This channel does not allow searching'), 5000) + this.showSearchBar = false + return + } + result = await this.channelInstance.search(this.lastSearchQuery) + contents = result.current_tab.content.contents + } else { + result = await this.searchContinuationData.getContinuation() + contents = result.contents.contents + } + + const results = contents + .filter(node => node.type === 'ItemSection') + .flatMap(itemSection => itemSection.contents) + .filter(item => item.type === 'Video' || item.type === 'Playlist') + .map(item => { + if (item.type === 'Video') { + return parseLocalListVideo(item) + } else { + return parseLocalListPlaylist(item, this.channelInstance.header.author) + } }) + + if (isNewSearch) { + this.searchResults = results + } else { + this.searchResults = this.searchResults.concat(results) + } + + this.searchContinuationData = result.has_continuation ? result : null + this.isElementListLoading = false + } catch (err) { + console.error(err) + const errorMessage = this.$t('Local API Error (Click to copy)') + showToast(`${errorMessage}: ${err}`, 10000, () => { + copyToClipboard(err) + }) + if (isNewSearch) { if (this.backendPreference === 'local' && this.backendFallback) { showToast(this.$t('Falling back to Invidious API')) this.searchChannelInvidious() } else { this.isLoading = false } - }) - } else { - ytch.searchChannelMore({ continuation: this.searchContinuationString }).then((response) => { - this.searchResults = this.searchResults.concat(response.items) - this.isElementListLoading = false - this.searchContinuationString = response.continuation - }).catch((err) => { - console.error(err) - const errorMessage = this.$t('Local API Error (Click to copy)') - showToast(`${errorMessage}: ${err}`, 10000, () => { - copyToClipboard(err) - }) - }) + } } }, diff --git a/src/renderer/views/Channel/Channel.vue b/src/renderer/views/Channel/Channel.vue index 3d25f9a7f..fc8d402b7 100644 --- a/src/renderer/views/Channel/Channel.vue +++ b/src/renderer/views/Channel/Channel.vue @@ -23,15 +23,22 @@ >
+
@@ -54,13 +61,14 @@