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 <pikachuexe@gmail.com>

* 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 <pikachuexe@gmail.com>
This commit is contained in:
absidue 2023-03-01 01:39:33 +01:00 committed by GitHub
parent 662ab372f5
commit 291aeff1a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 956 additions and 389 deletions

View File

@ -134,13 +134,7 @@ const config = {
alias: { alias: {
vue$: 'vue/dist/vue.common.js', vue$: 'vue/dist/vue.common.js',
// use the web version of linkedom 'youtubei.js$': 'youtubei.js/web',
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',
}, },
extensions: ['.js', '.vue'] extensions: ['.js', '.vue']
}, },

View File

@ -22,17 +22,10 @@ const config = {
path: path.join(__dirname, '../dist/web'), path: path.join(__dirname, '../dist/web'),
filename: '[name].js', filename: '[name].js',
}, },
externals: [ externals: {
{ electron: '{}',
electron: '{}' 'youtubei.js': '{}'
}, },
({ request }, callback) => {
if (request.startsWith('youtubei.js')) {
return callback(null, '{}')
}
callback()
}
],
module: { module: {
rules: [ rules: [
{ {

View File

@ -75,8 +75,7 @@
"vue-observe-visibility": "^1.0.0", "vue-observe-visibility": "^1.0.0",
"vue-router": "^3.6.5", "vue-router": "^3.6.5",
"vuex": "^3.6.2", "vuex": "^3.6.2",
"youtubei.js": "^2.9.0", "youtubei.js": "^3.1.0"
"yt-channel-info": "^3.2.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.21.0", "@babel/core": "^7.21.0",

View File

@ -430,11 +430,14 @@ export default defineComponent({
} }
case 'channel': { case 'channel': {
const { channelId, subPath } = result const { channelId, subPath, url } = result
openInternalPath({ openInternalPath({
path: `/channel/${channelId}/${subPath}`, path: `/channel/${channelId}/${subPath}`,
doCreateNewWindow doCreateNewWindow,
query: {
url
}
}) })
break break
} }

View File

@ -6,7 +6,6 @@ import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
import FtPrompt from '../ft-prompt/ft-prompt.vue' import FtPrompt from '../ft-prompt/ft-prompt.vue'
import { MAIN_PROFILE_ID } from '../../../constants' import { MAIN_PROFILE_ID } from '../../../constants'
import ytch from 'yt-channel-info'
import { calculateColorLuminance, getRandomColor } from '../../helpers/colors' import { calculateColorLuminance, getRandomColor } from '../../helpers/colors'
import { import {
copyToClipboard, copyToClipboard,
@ -17,6 +16,7 @@ import {
writeFileFromDialog writeFileFromDialog
} from '../../helpers/utils' } from '../../helpers/utils'
import { invidiousAPICall } from '../../helpers/api/invidious' import { invidiousAPICall } from '../../helpers/api/invidious'
import { getLocalChannel } from '../../helpers/api/local'
export default defineComponent({ export default defineComponent({
name: 'DataSettings', name: 'DataSettings',
@ -967,25 +967,32 @@ export default defineComponent({
}) })
}, },
getChannelInfoLocal: function (channelId) { getChannelInfoLocal: async function (channelId) {
return new Promise((resolve, reject) => { try {
ytch.getChannelInfo({ channelId: channelId }).then(async (response) => { const channel = await getLocalChannel(channelId)
resolve(response)
}).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') { if (channel.alert) {
showToast(this.$t('Falling back to the Invidious API')) return undefined
resolve(this.getChannelInfoInvidious(channelId)) }
} else {
resolve([]) 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 []
}
}
}, },
/* /*

View File

@ -175,12 +175,14 @@ export default defineComponent({
} }
case 'channel': { case 'channel': {
const { channelId, idType, subPath } = result const { channelId, subPath, url } = result
openInternalPath({ openInternalPath({
path: `/channel/${channelId}/${subPath}`, path: `/channel/${channelId}/${subPath}`,
query: { idType }, doCreateNewWindow,
doCreateNewWindow query: {
url
}
}) })
break break
} }

View File

@ -6,7 +6,7 @@ function getCurrentInstance() {
return store.getters.getCurrentInvidiousInstance return store.getters.getCurrentInvidiousInstance
} }
export function invidiousAPICall({ resource, id = '', params = {} }) { export function invidiousAPICall({ resource, id = '', params = {}, doLogError = true }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const requestUrl = getCurrentInstance() + '/api/v1/' + resource + '/' + id + '?' + new URLSearchParams(params).toString() const requestUrl = getCurrentInstance() + '/api/v1/' + resource + '/' + id + '?' + new URLSearchParams(params).toString()
@ -19,12 +19,39 @@ export function invidiousAPICall({ resource, id = '', params = {} }) {
resolve(json) resolve(json)
}) })
.catch((error) => { .catch((error) => {
console.error('Invidious API error', requestUrl, error) if (doLogError) {
console.error('Invidious API error', requestUrl, error)
}
reject(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) { export async function invidiousGetChannelInfo(channelId) {
return await invidiousAPICall({ return await invidiousAPICall({
resource: 'channels', resource: 'channels',

View File

@ -1,12 +1,10 @@
import { Innertube } from 'youtubei.js' import { Innertube, ClientType, Misc, Utils } 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 Autolinker from 'autolinker' import Autolinker from 'autolinker'
import { join } from 'path' import { join } from 'path'
import { PlayerCache } from './PlayerCache' import { PlayerCache } from './PlayerCache'
import { import {
CHANNEL_HANDLE_REGEX,
extractNumberFromString, extractNumberFromString,
getUserDataPath, getUserDataPath,
toLocalePublicationString toLocalePublicationString
@ -88,7 +86,7 @@ export async function getLocalTrending(location, tab, instance) {
const results = resultsInstance.videos const results = resultsInstance.videos
.filter((video) => video.type === 'Video') .filter((video) => video.type === 'Video')
.map(parseListVideo) .map(parseLocalListVideo)
return { return {
results, 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 * @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
*/ */
export function parseLocalListVideo(video) {
/**
* @param {Video} video
*/
function parseListVideo(video) {
return { return {
type: 'video', type: 'video',
videoId: video.id, videoId: video.id,
@ -231,20 +336,14 @@ function parseListVideo(video) {
} }
/** /**
* @typedef {import('youtubei.js/dist/src/parser/helpers').YTNode} YTNode * @param {import('youtubei.js/dist/src/parser/helpers').YTNode} item
* @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
*/ */
function parseListItem(item) { function parseListItem(item) {
switch (item.type) { switch (item.type) {
case 'Video': case 'Video':
return parseListVideo(item) return parseLocalListVideo(item)
case 'Channel': { case 'Channel': {
/** @type {Channel} */ /** @type {import('youtubei.js/dist/src/parser/classes/Channel').default} */
const channel = item const channel = item
// see upstream TODO: https://github.com/LuanRT/YouTube.js/blob/main/src/parser/classes/Channel.ts#L33 // 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': { case 'Playlist': {
/** @type {Playlist} */ return parseLocalListPlaylist(item)
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)
}
} }
} }
} }
@ -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/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 = [] const parsedRuns = []
for (const run of runs) { for (const run of runs) {
if (run instanceof EmojiRun) { if (run instanceof Misc.EmojiRun) {
const { emoji, text } = run const { emoji, text } = run
// empty array if video creator removes a channel emoji so we ignore. // empty array if video creator removes a channel emoji so we ignore.
@ -413,7 +491,7 @@ export function parseLocalTextRuns(runs, emojiSize = 16) {
break break
case 'WEB_PAGE_TYPE_CHANNEL': { case 'WEB_PAGE_TYPE_CHANNEL': {
const trimmedText = text.trim() const trimmedText = text.trim()
if (trimmedText.startsWith('@')) { if (CHANNEL_HANDLE_REGEX.test(trimmedText)) {
parsedRuns.push(`<a href="https://www.youtube.com/channel/${endpoint.payload.browseId}">${trimmedText}</a>`) parsedRuns.push(`<a href="https://www.youtube.com/channel/${endpoint.payload.browseId}">${trimmedText}</a>`)
} else { } else {
parsedRuns.push(`https://www.youtube.com${endpoint.metadata.url}`) parsedRuns.push(`https://www.youtube.com${endpoint.metadata.url}`)
@ -548,3 +626,32 @@ export function filterFormats(formats, allowAv1 = false) {
return [...audioFormats, ...h264Formats] 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
}

View File

@ -5,6 +5,10 @@ import FtToastEvents from '../components/ft-toast/ft-toast-events'
import i18n from '../i18n/index' import i18n from '../i18n/index'
import router from '../router/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) { export function calculatePublishedDate(publishedText) {
const date = new Date() const date = new Date()
if (publishedText === 'Live') { if (publishedText === 'Live') {

View File

@ -14,6 +14,7 @@ import {
faBookmark, faBookmark,
faCheck, faCheck,
faChevronRight, faChevronRight,
faCircleUser,
faClone, faClone,
faCommentDots, faCommentDots,
faCopy, faCopy,
@ -77,6 +78,7 @@ library.add(
faBookmark, faBookmark,
faCheck, faCheck,
faChevronRight, faChevronRight,
faCircleUser,
faClone, faClone,
faCommentDots, faCommentDots,
faCopy, faCopy,

View File

@ -5,6 +5,7 @@ import i18n from '../../i18n/index'
import { IpcChannels } from '../../../constants' import { IpcChannels } from '../../../constants'
import { pathExists } from '../../helpers/filesystem' import { pathExists } from '../../helpers/filesystem'
import { import {
CHANNEL_HANDLE_REGEX,
createWebURL, createWebURL,
getVideoParamsFromUrl, getVideoParamsFromUrl,
openExternalLink, openExternalLink,
@ -261,7 +262,7 @@ const actions = {
commit('setRegionValues', regionValues) commit('setRegionValues', regionValues)
}, },
getYoutubeUrlInfo ({ state }, urlStr) { async getYoutubeUrlInfo({ rootState, state }, urlStr) {
// Returns // Returns
// - urlType [String] `video`, `playlist` // - urlType [String] `video`, `playlist`
// //
@ -288,6 +289,11 @@ const actions = {
// //
// If `urlType` is "invalid_url" // If `urlType` is "invalid_url"
// Nothing else // Nothing else
if (CHANNEL_HANDLE_REGEX.test(urlStr)) {
urlStr = `https://www.youtube.com/${urlStr}`
}
const { videoId, timestamp, playlistId } = getVideoParamsFromUrl(urlStr) const { videoId, timestamp, playlistId } = getVideoParamsFromUrl(urlStr)
if (videoId) { if (videoId) {
return { return {
@ -309,7 +315,7 @@ const actions = {
let urlType = 'unknown' let urlType = 'unknown'
const channelPattern = const channelPattern =
/^\/(?:(?<type>channel|user|c)\/)?(?<channelId>[^/]+)(?:\/(join|featured|videos|playlists|about|community|channels))?\/?$/ /^\/(?:(?:channel|user|c)\/)?(?<channelId>[^/]+)(?:\/(join|featured|videos|playlists|about|community|channels))?\/?$/
const typePatterns = new Map([ const typePatterns = new Map([
['playlist', /^(\/playlist\/?|\/embed(\/?videoseries)?)$/], ['playlist', /^(\/playlist\/?|\/embed(\/?videoseries)?)$/],
@ -409,7 +415,6 @@ const actions = {
case 'channel': { case 'channel': {
const match = url.pathname.match(channelPattern) const match = url.pathname.match(channelPattern)
const channelId = match.groups.channelId const channelId = match.groups.channelId
const idType = ['channel', 'user', 'c'].indexOf(match.groups.type) + 1
if (!channelId) { if (!channelId) {
throw new Error('Channel: could not extract id') throw new Error('Channel: could not extract id')
} }
@ -431,8 +436,8 @@ const actions = {
return { return {
urlType: 'channel', urlType: 'channel',
channelId, channelId,
idType, subPath,
subPath url: url.toString()
} }
} }

View File

@ -18,6 +18,7 @@
} }
.channelBannerContainer.default { .channelBannerContainer.default {
background-color: black;
background-image: url("../../assets/img/defaultBanner.png"); background-image: url("../../assets/img/defaultBanner.png");
background-repeat: repeat; background-repeat: repeat;
background-size: contain; background-size: contain;
@ -37,11 +38,16 @@
justify-content: space-between; justify-content: space-between;
} }
.channelInfoHasError {
padding-bottom: 10px;
}
.channelThumbnail { .channelThumbnail {
width: 100px; width: 100px;
height: 100px; height: 100px;
border-radius: 200px 200px 200px 200px; border-radius: 200px 200px 200px 200px;
-webkit-border-radius: 200px 200px 200px 200px; -webkit-border-radius: 200px 200px 200px 200px;
object-fit: cover;
} }
.channelName { .channelName {
@ -58,7 +64,7 @@
.channelInfoActionsContainer { .channelInfoActionsContainer {
display: flex; display: flex;
min-width: 230px; gap: 30px;
justify-content: space-between; justify-content: space-between;
} }
@ -137,6 +143,36 @@
white-space: pre-wrap; 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 { .channelSearch {
margin-top: 10px; margin-top: 10px;
max-width: 250px; max-width: 250px;

View File

@ -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 FtAgeRestricted from '../../components/ft-age-restricted/ft-age-restricted.vue'
import FtShareButton from '../../components/ft-share-button/ft-share-button.vue' import FtShareButton from '../../components/ft-share-button/ft-share-button.vue'
import ytch from 'yt-channel-info'
import autolinker from 'autolinker' import autolinker from 'autolinker'
import { MAIN_PROFILE_ID } from '../../../constants' 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 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({ export default defineComponent({
name: 'Search', name: 'Channel',
components: { components: {
'ft-card': FtCard, 'ft-card': FtCard,
'ft-button': FtButton, 'ft-button': FtButton,
@ -38,18 +50,22 @@ export default defineComponent({
isElementListLoading: false, isElementListLoading: false,
currentTab: 'videos', currentTab: 'videos',
id: '', id: '',
idType: 0, channelInstance: null,
channelName: '', channelName: '',
bannerUrl: '', bannerUrl: '',
thumbnailUrl: '', thumbnailUrl: '',
subCount: 0, subCount: 0,
searchPage: 2, searchPage: 2,
videoContinuationString: '', videoContinuationData: null,
playlistContinuationString: '', playlistContinuationData: null,
searchContinuationString: '', searchContinuationData: null,
channelDescription: '', description: '',
tags: [],
views: 0,
joined: 0,
location: null,
videoSortBy: 'newest', videoSortBy: 'newest',
playlistSortBy: 'last', playlistSortBy: 'newest',
lastSearchQuery: '', lastSearchQuery: '',
relatedChannels: [], relatedChannels: [],
latestVideos: [], latestVideos: [],
@ -59,14 +75,15 @@ export default defineComponent({
apiUsed: '', apiUsed: '',
isFamilyFriendly: false, isFamilyFriendly: false,
errorMessage: '', errorMessage: '',
showSearchBar: true,
showShareMenu: true,
videoSelectValues: [ videoSelectValues: [
'newest', 'newest',
'oldest',
'popular' 'popular'
], ],
playlistSelectValues: [ playlistSelectValues: [
'last', 'newest',
'newest' 'last'
], ],
tabInfoValues: [ tabInfoValues: [
'videos', 'videos',
@ -100,6 +117,10 @@ export default defineComponent({
return this.$store.getters.getSessionSearchHistory return this.$store.getters.getSessionSearchHistory
}, },
currentLocale: function () {
return this.$i18n.locale.replace('_', '-')
},
profileList: function () { profileList: function () {
return this.$store.getters.getProfileList return this.$store.getters.getProfileList
}, },
@ -129,15 +150,14 @@ export default defineComponent({
videoSelectNames: function () { videoSelectNames: function () {
return [ return [
this.$t('Channel.Videos.Sort Types.Newest'), this.$t('Channel.Videos.Sort Types.Newest'),
this.$t('Channel.Videos.Sort Types.Oldest'),
this.$t('Channel.Videos.Sort Types.Most Popular') this.$t('Channel.Videos.Sort Types.Most Popular')
] ]
}, },
playlistSelectNames: function () { playlistSelectNames: function () {
return [ 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) 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 () { showFetchMoreButton: function () {
switch (this.currentTab) { switch (this.currentTab) {
case 'videos': case 'videos':
if (this.apiUsed === 'invidious' || (this.videoContinuationString !== '' && this.videoContinuationString !== null)) { if (this.videoContinuationData !== null) {
return true return true
} }
break break
case 'playlists': case 'playlists':
if (this.playlistContinuationString !== '' && this.playlistContinuationString !== null) { if (this.playlistContinuationData !== null) {
return true return true
} }
break break
case 'search': case 'search':
if (this.searchContinuationString !== '' && this.searchContinuationString !== null) { if (this.searchContinuationData !== null) {
return true return true
} }
break break
@ -171,14 +199,31 @@ export default defineComponent({
}, },
hideChannelSubscriptions: function () { hideChannelSubscriptions: function () {
return this.$store.getters.getHideChannelSubscriptions 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: { watch: {
$route() { $route() {
// react to route changes... // 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.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.currentTab = this.$route.params.currentTab ?? 'videos'
this.searchPage = 2 this.searchPage = 2
this.relatedChannels = [] this.relatedChannels = []
@ -187,15 +232,25 @@ export default defineComponent({
this.searchResults = [] this.searchResults = []
this.shownElementList = [] this.shownElementList = []
this.apiUsed = '' 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') { if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
this.getChannelInfoInvidious() this.getChannelInfoInvidious()
this.getPlaylistsInvidious()
} else { } else {
this.getChannelInfoLocal() this.getChannelLocal()
this.getChannelVideosLocal()
this.getPlaylistsLocal()
} }
}, },
@ -207,7 +262,7 @@ export default defineComponent({
this.getChannelVideosLocal() this.getChannelVideosLocal()
break break
case 'invidious': case 'invidious':
this.channelInvidiousVideos() this.channelInvidiousVideos(true)
break break
default: default:
this.getChannelVideosLocal() this.getChannelVideosLocal()
@ -217,95 +272,247 @@ export default defineComponent({
playlistSortBy () { playlistSortBy () {
this.isElementListLoading = true this.isElementListLoading = true
this.latestPlaylists = [] this.latestPlaylists = []
this.playlistContinuationString = '' this.playlistContinuationData = null
switch (this.apiUsed) { switch (this.apiUsed) {
case 'local': case 'local':
this.getPlaylistsLocal() this.getChannelPlaylistsLocal()
break break
case 'invidious': case 'invidious':
this.getPlaylistsInvidious() this.getPlaylistsInvidious()
break break
default: default:
this.getPlaylistsLocal() this.getChannelPlaylistsLocal()
} }
} }
}, },
mounted: function () { 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 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') { if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
this.getChannelInfoInvidious() this.getChannelInfoInvidious()
this.getPlaylistsInvidious()
} else { } else {
this.getChannelInfoLocal() this.getChannelLocal()
this.getChannelVideosLocal()
this.getPlaylistsLocal()
} }
}, },
methods: { 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) { goToChannel: function (id) {
this.$router.push({ path: `/channel/${id}` }) this.$router.push({ path: `/channel/${id}` })
}, },
getChannelInfoLocal: function () { getChannelLocal: async function () {
this.apiUsed = 'local' this.apiUsed = 'local'
const expectedId = this.originalId this.isLoading = true
ytch.getChannelInfo({ channelId: this.id, channelIdType: this.idType }).then((response) => { const expectedId = this.id
if (response.alertMessage) {
this.setErrorMessage(response.alertMessage) 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 return
} }
this.errorMessage = '' this.errorMessage = ''
if (expectedId !== this.originalId) { if (expectedId !== this.id) {
return return
} }
const channelId = response.authorId let channelId
const channelName = response.author let subscriberText = null
const channelThumbnailUrl = response.authorThumbnails[2].url let tags = []
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
})
if (response.authorBanners !== null) { switch (channel.header.type) {
const bannerUrl = response.authorBanners[response.authorBanners.length - 1].url case 'C4TabbedHeader': {
// example: Linus Tech Tips
// https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw
if (!bannerUrl.includes('https')) { /**
this.bannerUrl = `https://${bannerUrl}` * @type {import('youtubei.js/dist/src/parser/classes/C4TabbedHeader').default}
} else { */
this.bannerUrl = bannerUrl 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 { } else {
this.bannerUrl = null 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 this.isLoading = false
}).catch((err) => { } catch (err) {
console.error(err) console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)') const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => { showToast(`${errorMessage}: ${err}`, 10000, () => {
@ -317,21 +524,64 @@ export default defineComponent({
} else { } else {
this.isLoading = false 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 this.isElementListLoading = true
const expectedId = this.originalId const expectedId = this.id
ytch.getChannelVideos({ channelId: this.id, channelIdType: this.idType, sortBy: this.videoSortBy }).then((response) => {
if (expectedId !== this.originalId) { 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 return
} }
this.latestVideos = response.items this.latestVideos = parseLocalChannelVideos(videosTab.videos, channel.header.author)
this.videoContinuationString = response.continuation this.videoContinuationData = videosTab.has_continuation ? videosTab : null
this.isElementListLoading = false this.isElementListLoading = false
}).catch((err) => { } catch (err) {
console.error(err) console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)') const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => { showToast(`${errorMessage}: ${err}`, 10000, () => {
@ -343,29 +593,35 @@ export default defineComponent({
} else { } else {
this.isLoading = false this.isLoading = false
} }
}) }
}, },
channelLocalNextPage: function () { channelLocalNextPage: async function () {
ytch.getChannelVideosMore({ continuation: this.videoContinuationString }).then((response) => { try {
this.latestVideos = this.latestVideos.concat(response.items) /**
this.videoContinuationString = response.continuation * @type {import('youtubei.js/dist/src/parser/youtube/Channel').ChannelListContinuation|import('youtubei.js/dist/src/parser/youtube/Channel').FilteredChannelList}
}).catch((err) => { */
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) console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)') const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => { showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err) copyToClipboard(err)
}) })
}) }
}, },
getChannelInfoInvidious: function () { getChannelInfoInvidious: function () {
this.isLoading = true this.isLoading = true
this.apiUsed = 'invidious' this.apiUsed = 'invidious'
this.channelInstance = null
const expectedId = this.originalId const expectedId = this.id
invidiousGetChannelInfo(this.id).then((response) => { invidiousGetChannelInfo(this.id).then((response) => {
if (expectedId !== this.originalId) { if (expectedId !== this.id) {
return return
} }
@ -383,14 +639,16 @@ export default defineComponent({
const thumbnail = response.authorThumbnails[3].url const thumbnail = response.authorThumbnails[3].url
this.thumbnailUrl = youtubeImageUrlToInvidious(thumbnail, this.currentInvidiousInstance) this.thumbnailUrl = youtubeImageUrlToInvidious(thumbnail, this.currentInvidiousInstance)
this.updateSubscriptionDetails({ channelThumbnailUrl: thumbnail, channelName: channelName, channelId: channelId }) 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) => { this.relatedChannels = response.relatedChannels.map((channel) => {
channel.authorThumbnails = channel.authorThumbnails.map(thumbnail => { const thumbnailUrl = channel.authorThumbnails.at(-1).url
thumbnail.url = youtubeImageUrlToInvidious(thumbnail.url, this.currentInvidiousInstance) return {
return thumbnail name: channel.author,
}) id: channel.authorId,
channel.channelId = channel.authorId thumbnailUrl: youtubeImageUrlToInvidious(thumbnailUrl, this.currentInvidiousInstance)
return channel }
}) })
this.latestVideos = response.latestVideos this.latestVideos = response.latestVideos
@ -401,19 +659,36 @@ export default defineComponent({
} }
this.errorMessage = '' 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 this.isLoading = false
}).catch((err) => { }).catch((err) => {
this.setErrorMessage(err.responseJSON.error) this.setErrorMessage(err)
console.error(err) console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)') const errorMessage = this.$t('Invidious API Error (Click to copy)')
showToast(`${errorMessage}: ${err.responseJSON.error}`, 10000, () => { showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err.responseJSON.error) 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 = { const payload = {
resource: 'channels/videos', resource: 'channels/videos',
id: this.id, id: this.id,
@ -421,11 +696,28 @@ export default defineComponent({
sort_by: this.videoSortBy, 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) => { invidiousAPICall(payload).then((response) => {
this.latestVideos = this.latestVideos.concat(response.videos) if (more) {
this.videoContinuationString = response.continuation this.latestVideos = this.latestVideos.concat(response.videos)
} else {
this.latestVideos = response.videos
}
this.videoContinuationData = response.continuation || null
this.isElementListLoading = false this.isElementListLoading = false
}).catch((err) => { }).catch((err) => {
console.error(err) console.error(err)
@ -436,20 +728,45 @@ export default defineComponent({
}) })
}, },
getPlaylistsLocal: function () { getChannelPlaylistsLocal: async function () {
const expectedId = this.originalId const expectedId = this.id
ytch.getChannelPlaylistInfo({ channelId: this.id, channelIdType: this.idType, sortBy: this.playlistSortBy }).then((response) => {
if (expectedId !== this.originalId) { 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 return
} }
this.latestPlaylists = response.items.map((item) => { this.latestPlaylists = playlistsTab.playlists.map(playlist => parseLocalListPlaylist(playlist, channel.header.author))
item.proxyThumbnail = false this.playlistContinuationData = playlistsTab.has_continuation ? playlistsTab : null
return item
})
this.playlistContinuationString = response.continuation
this.isElementListLoading = false this.isElementListLoading = false
}).catch((err) => { } catch (err) {
console.error(err) console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)') const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => { showToast(`${errorMessage}: ${err}`, 10000, () => {
@ -461,23 +778,30 @@ export default defineComponent({
} else { } else {
this.isLoading = false this.isLoading = false
} }
}) }
}, },
getPlaylistsLocalMore: function () { getChannelPlaylistsLocalMore: async function () {
ytch.getChannelPlaylistsMore({ continuation: this.playlistContinuationString }).then((response) => { try {
this.latestPlaylists = this.latestPlaylists.concat(response.items) /**
this.playlistContinuationString = response.continuation * @type {import('youtubei.js/dist/src/parser/youtube/Channel').ChannelListContinuation}
}).catch((err) => { */
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) console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)') const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => { showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err) copyToClipboard(err)
}) })
}) }
}, },
getPlaylistsInvidious: function () { getPlaylistsInvidious: function () {
this.isElementListLoading = true
const payload = { const payload = {
resource: 'channels/playlists', resource: 'channels/playlists',
id: this.id, id: this.id,
@ -487,18 +811,18 @@ export default defineComponent({
} }
invidiousAPICall(payload).then((response) => { invidiousAPICall(payload).then((response) => {
this.playlistContinuationString = response.continuation this.playlistContinuationData = response.continuation || null
this.latestPlaylists = response.playlists this.latestPlaylists = response.playlists
this.isElementListLoading = false this.isElementListLoading = false
}).catch((err) => { }).catch((err) => {
console.error(err) console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)') const errorMessage = this.$t('Invidious API Error (Click to copy)')
showToast(`${errorMessage}: ${err.responseJSON.error}`, 10000, () => { showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err.responseJSON.error) copyToClipboard(err)
}) })
if (process.env.IS_ELECTRON && this.backendPreference === 'invidious' && this.backendFallback) { if (process.env.IS_ELECTRON && this.backendPreference === 'invidious' && this.backendFallback) {
showToast(this.$t('Falling back to Local API')) showToast(this.$t('Falling back to Local API'))
this.getPlaylistsLocal() this.getChannelLocal()
} else { } else {
this.isLoading = false this.isLoading = false
} }
@ -506,7 +830,7 @@ export default defineComponent({
}, },
getPlaylistsInvidiousMore: function () { getPlaylistsInvidiousMore: function () {
if (this.playlistContinuationString === null) { if (this.playlistContinuationData === null) {
console.warn('There are no more playlists available for this channel') console.warn('There are no more playlists available for this channel')
return return
} }
@ -519,23 +843,23 @@ export default defineComponent({
} }
} }
if (this.playlistContinuationString) { if (this.playlistContinuationData) {
payload.params.continuation = this.playlistContinuationString payload.params.continuation = this.playlistContinuationData
} }
invidiousAPICall(payload).then((response) => { invidiousAPICall(payload).then((response) => {
this.playlistContinuationString = response.continuation this.playlistContinuationData = response.continuation || null
this.latestPlaylists = this.latestPlaylists.concat(response.playlists) this.latestPlaylists = this.latestPlaylists.concat(response.playlists)
this.isElementListLoading = false this.isElementListLoading = false
}).catch((err) => { }).catch((err) => {
console.error(err) console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)') const errorMessage = this.$t('Invidious API Error (Click to copy)')
showToast(`${errorMessage}: ${err.responseJSON.error}`, 10000, () => { showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err.responseJSON.error) copyToClipboard(err)
}) })
if (process.env.IS_ELECTRON && this.backendPreference === 'invidious' && this.backendFallback) { if (process.env.IS_ELECTRON && this.backendPreference === 'invidious' && this.backendFallback) {
showToast(this.$t('Falling back to Local API')) showToast(this.$t('Falling back to Local API'))
this.getPlaylistsLocal() this.getChannelLocal()
} else { } else {
this.isLoading = false this.isLoading = false
} }
@ -608,12 +932,14 @@ export default defineComponent({
} }
}, },
setErrorMessage: function (errorMessage) { setErrorMessage: function (errorMessage, responseHasNameAndThumbnail = false) {
this.isLoading = false this.isLoading = false
this.errorMessage = errorMessage this.errorMessage = errorMessage
this.id = this.subscriptionInfo.id
this.channelName = this.subscriptionInfo.name if (!responseHasNameAndThumbnail) {
this.thumbnailUrl = this.subscriptionInfo.thumbnail this.channelName = this.subscriptionInfo?.name
this.thumbnailUrl = this.subscriptionInfo?.thumbnail
}
this.bannerUrl = null this.bannerUrl = null
this.subCount = null this.subCount = null
}, },
@ -626,14 +952,14 @@ export default defineComponent({
this.channelLocalNextPage() this.channelLocalNextPage()
break break
case 'invidious': case 'invidious':
this.channelInvidiousVideos(true) this.channelInvidiousVideos()
break break
} }
break break
case 'playlists': case 'playlists':
switch (this.apiUsed) { switch (this.apiUsed) {
case 'local': case 'local':
this.getPlaylistsLocalMore() this.getChannelPlaylistsLocalMore()
break break
case 'invidious': case 'invidious':
this.getPlaylistsInvidiousMore() this.getPlaylistsInvidiousMore()
@ -687,7 +1013,7 @@ export default defineComponent({
newSearch: function (query) { newSearch: function (query) {
this.lastSearchQuery = query this.lastSearchQuery = query
this.searchContinuationString = '' this.searchContinuationData = null
this.isElementListLoading = true this.isElementListLoading = true
this.searchPage = 1 this.searchPage = 1
this.searchResults = [] this.searchResults = []
@ -702,37 +1028,58 @@ export default defineComponent({
} }
}, },
searchChannelLocal: function () { searchChannelLocal: async function () {
if (this.searchContinuationString === '') { const isNewSearch = this.searchContinuationData === null
ytch.searchChannel({ channelId: this.id, channelIdType: this.idType, query: this.lastSearchQuery }).then((response) => { try {
this.searchResults = response.items let result
this.isElementListLoading = false let contents
this.searchContinuationString = response.continuation if (isNewSearch) {
}).catch((err) => { if (!this.channelInstance.has_search) {
console.error(err) showToast(this.$t('Channel.This channel does not allow searching'), 5000)
const errorMessage = this.$t('Local API Error (Click to copy)') this.showSearchBar = false
showToast(`${errorMessage}: ${err}`, 10000, () => { return
copyToClipboard(err) }
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) { if (this.backendPreference === 'local' && this.backendFallback) {
showToast(this.$t('Falling back to Invidious API')) showToast(this.$t('Falling back to Invidious API'))
this.searchChannelInvidious() this.searchChannelInvidious()
} else { } else {
this.isLoading = false 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)
})
})
} }
}, },

View File

@ -23,15 +23,22 @@
> >
<div <div
class="channelInfo" class="channelInfo"
:class="{ channelInfoHasError: errorMessage }"
> >
<div <div
class="thumbnailContainer" class="thumbnailContainer"
> >
<img <img
v-if="thumbnailUrl"
class="channelThumbnail" class="channelThumbnail"
:src="thumbnailUrl" :src="thumbnailUrl"
alt="" alt=""
> >
<font-awesome-icon
v-else
class="channelThumbnail"
:icon="['fas', 'circle-user']"
/>
<div <div
class="channelLineContainer" class="channelLineContainer"
> >
@ -54,13 +61,14 @@
<div class="channelInfoActionsContainer"> <div class="channelInfoActionsContainer">
<ft-share-button <ft-share-button
v-if="!hideSharingActions && showShareMenu"
:id="id" :id="id"
share-target-type="Channel" share-target-type="Channel"
class="shareIcon" class="shareIcon"
/> />
<ft-button <ft-button
v-if="!hideUnsubscribeButton" v-if="!hideUnsubscribeButton && (!errorMessage || isSubscribed)"
:label="subscribedText" :label="subscribedText"
background-color="var(--primary-color)" background-color="var(--primary-color)"
text-color="var(--text-with-main-color)" text-color="var(--text-with-main-color)"
@ -121,6 +129,7 @@
</div> </div>
<ft-input <ft-input
v-if="showSearchBar"
:placeholder="$t('Channel.Search Channel')" :placeholder="$t('Channel.Search Channel')"
:show-clear-text-button="true" :show-clear-text-button="true"
class="channelSearch" class="channelSearch"
@ -138,14 +147,89 @@
id="aboutPanel" id="aboutPanel"
class="aboutTab" class="aboutTab"
> >
<h2> <h2
v-if="description"
>
{{ $t("Channel.About.Channel Description") }} {{ $t("Channel.About.Channel Description") }}
</h2> </h2>
<div <div
v-if="description"
class="aboutInfo" class="aboutInfo"
v-html="channelDescription" v-html="description"
/> />
<br> <h2
v-if="joined || views !== null || location"
>
{{ $t('Channel.About.Details') }}
</h2>
<table
v-if="joined || views !== null || location"
class="aboutDetails"
>
<tr
v-if="joined"
>
<th
scope="row"
>
{{ $t('Channel.About.Joined') }}
</th>
<td>{{ formattedJoined }}</td>
</tr>
<tr
v-if="views !== null"
>
<th
scope="row"
>
{{ $t('Video.Views') }}
</th>
<td>{{ formattedViews }}</td>
</tr>
<tr
v-if="location"
>
<th
scope="row"
>
{{ $t('Channel.About.Location') }}
</th>
<td>{{ location }}</td>
</tr>
</table>
<h2
v-if="tags.length > 0"
>
{{ $t('Channel.About.Tags.Tags') }}
</h2>
<ul
v-if="tags.length > 0"
class="aboutTags"
>
<li
v-for="tag in tags"
:key="tag"
class="aboutTag"
>
<router-link
v-if="!hideSearchBar"
class="aboutTagLink"
:title="$t('Channel.About.Tags.Search for', { tag })"
:to="{
path: `/search/${encodeURIComponent(tag)}`,
query: searchSettings
}"
>
{{ tag }}
</router-link>
<span
v-else
class="aboutTagLink"
>
{{ tag }}
</span>
</li>
</ul>
<h2 <h2
v-if="relatedChannels.length > 0" v-if="relatedChannels.length > 0"
> >
@ -157,16 +241,16 @@
<ft-channel-bubble <ft-channel-bubble
v-for="(channel, index) in relatedChannels" v-for="(channel, index) in relatedChannels"
:key="index" :key="index"
:channel-name="channel.author || channel.channelName" :channel-name="channel.name"
:channel-id="channel.channelId" :channel-id="channel.id"
:channel-thumbnail="channel.authorThumbnails[channel.authorThumbnails.length - 1].url" :channel-thumbnail="channel.thumbnailUrl"
role="link" role="link"
@click="goToChannel(channel.channelId)" @click="goToChannel(channel.id)"
/> />
</ft-flex-box> </ft-flex-box>
</div> </div>
<ft-select <ft-select
v-show="currentTab === 'videos'" v-show="currentTab === 'videos' && latestVideos.length > 0"
class="sortSelect" class="sortSelect"
:value="videoSelectValues[0]" :value="videoSelectValues[0]"
:select-names="videoSelectNames" :select-names="videoSelectNames"
@ -175,7 +259,7 @@
@change="videoSortBy = $event" @change="videoSortBy = $event"
/> />
<ft-select <ft-select
v-show="currentTab === 'playlists'" v-show="currentTab === 'playlists' && latestPlaylists.length > 0"
class="sortSelect" class="sortSelect"
:value="playlistSelectValues[0]" :value="playlistSelectValues[0]"
:select-names="playlistSelectNames" :select-names="playlistSelectNames"

View File

@ -5,9 +5,9 @@ import FtCard from '../../components/ft-card/ft-card.vue'
import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue' import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
import FtInput from '../../components/ft-input/ft-input.vue' import FtInput from '../../components/ft-input/ft-input.vue'
import FtPrompt from '../../components/ft-prompt/ft-prompt.vue' import FtPrompt from '../../components/ft-prompt/ft-prompt.vue'
import ytch from 'yt-channel-info'
import { showToast } from '../../helpers/utils' import { showToast } from '../../helpers/utils'
import { invidiousGetChannelInfo, youtubeImageUrlToInvidious, invidiousImageUrlToInvidious } from '../../helpers/api/invidious' import { invidiousGetChannelInfo, youtubeImageUrlToInvidious, invidiousImageUrlToInvidious } from '../../helpers/api/invidious'
import { getLocalChannel } from '../../helpers/api/local'
export default defineComponent({ export default defineComponent({
name: 'SubscribedChannels', name: 'SubscribedChannels',
@ -182,12 +182,14 @@ export default defineComponent({
if (this.backendPreference === 'local') { if (this.backendPreference === 'local') {
// avoid too many concurrent requests // avoid too many concurrent requests
setTimeout(() => { setTimeout(() => {
ytch.getChannelInfo({ channelId: channel.id }).then(response => { getLocalChannel(channel.id).then(response => {
this.updateSubscriptionDetails({ if (!response.alert) {
channelThumbnailUrl: this.thumbnailURL(response.authorThumbnails[0].url), this.updateSubscriptionDetails({
channelName: channel.name, channelThumbnailUrl: this.thumbnailURL(response.header.author.thumbnails[0].url),
channelId: channel.id channelName: channel.name,
}) channelId: channel.id
})
}
}) })
}, this.errorCount * 500) }, this.errorCount * 500)
} else { } else {

View File

@ -8,10 +8,10 @@ import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue'
import FtElementList from '../../components/ft-element-list/ft-element-list.vue' import FtElementList from '../../components/ft-element-list/ft-element-list.vue'
import FtChannelBubble from '../../components/ft-channel-bubble/ft-channel-bubble.vue' import FtChannelBubble from '../../components/ft-channel-bubble/ft-channel-bubble.vue'
import ytch from 'yt-channel-info'
import { MAIN_PROFILE_ID } from '../../../constants' import { MAIN_PROFILE_ID } from '../../../constants'
import { calculatePublishedDate, copyToClipboard, showToast } from '../../helpers/utils' import { calculatePublishedDate, copyToClipboard, showToast } from '../../helpers/utils'
import { invidiousAPICall } from '../../helpers/api/invidious' import { invidiousAPICall } from '../../helpers/api/invidious'
import { getLocalChannelVideos } from '../../helpers/api/local'
export default defineComponent({ export default defineComponent({
name: 'Subscriptions', name: 'Subscriptions',
@ -270,50 +270,47 @@ export default defineComponent({
} }
}, },
getChannelVideosLocalScraper: function (channel, failedAttempts = 0) { getChannelVideosLocalScraper: async function (channel, failedAttempts = 0) {
return new Promise((resolve, reject) => { try {
ytch.getChannelVideos({ channelId: channel.id, sortBy: 'latest' }).then((response) => { const videos = await getLocalChannelVideos(channel.id)
if (response.alertMessage) {
this.errorChannels.push(channel)
resolve([])
return
}
const videos = response.items.map((video) => {
if (video.liveNow) {
video.publishedDate = new Date().getTime()
} else {
video.publishedDate = calculatePublishedDate(video.publishedText)
}
return video
})
resolve(videos) if (videos === null) {
}).catch((err) => { this.errorChannels.push(channel)
console.error(err) return []
const errorMessage = this.$t('Local API Error (Click to copy)') }
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err) videos.map(video => {
}) if (video.liveNow) {
switch (failedAttempts) { video.publishedDate = new Date().getTime()
case 0: } else {
resolve(this.getChannelVideosLocalRSS(channel, failedAttempts + 1)) video.publishedDate = calculatePublishedDate(video.publishedText)
break
case 1:
if (this.backendFallback) {
showToast(this.$t('Falling back to Invidious API'))
resolve(this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1))
} else {
resolve([])
}
break
case 2:
resolve(this.getChannelVideosLocalRSS(channel, failedAttempts + 1))
break
default:
resolve([])
} }
return video
}) })
})
return videos
} catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
switch (failedAttempts) {
case 0:
return await this.getChannelVideosLocalRSS(channel, failedAttempts + 1)
case 1:
if (this.backendFallback) {
showToast(this.$t('Falling back to Invidious API'))
return await this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1)
} else {
return []
}
case 2:
return await this.getChannelVideosLocalRSS(channel, failedAttempts + 1)
default:
return []
}
}
}, },
getChannelVideosLocalRSS: async function (channel, failedAttempts = 0) { getChannelVideosLocalRSS: async function (channel, failedAttempts = 0) {

View File

@ -15,7 +15,6 @@ import { pathExists } from '../../helpers/filesystem'
import { import {
buildVTTFileLocally, buildVTTFileLocally,
copyToClipboard, copyToClipboard,
extractNumberFromString,
formatDurationAsTimestamp, formatDurationAsTimestamp,
formatNumber, formatNumber,
getFormatsFromHLSManifest, getFormatsFromHLSManifest,
@ -26,6 +25,7 @@ import {
filterFormats, filterFormats,
getLocalVideoInfo, getLocalVideoInfo,
mapLocalFormat, mapLocalFormat,
parseLocalSubscriberCount,
parseLocalTextRuns, parseLocalTextRuns,
parseLocalWatchNextVideo parseLocalWatchNextVideo
} from '../../helpers/api/local' } from '../../helpers/api/local'
@ -336,27 +336,7 @@ export default defineComponent({
this.isLiveContent = !!result.basic_info.is_live_content this.isLiveContent = !!result.basic_info.is_live_content
if (!this.hideChannelSubscriptions) { if (!this.hideChannelSubscriptions) {
// really not a fan of this :(, YouTube returns the subscribers as "15.1M subscribers" const subCount = parseLocalSubscriberCount(result.secondary_info.owner.subscriber_count.text)
// so we have to parse it somehow
const rawSubCount = result.secondary_info.owner.subscriber_count.text
const match = rawSubCount
.replace(',', '.')
.toUpperCase()
.match(/([\d.]+)\s*([KM]?)/)
let subCount
if (match) {
subCount = parseFloat(match[1])
if (match[2] === 'K') {
subCount *= 1000
} else if (match[2] === 'M') {
subCount *= 1000_000
}
subCount = Math.trunc(subCount)
} else {
subCount = extractNumberFromString(rawSubCount)
}
if (!isNaN(subCount)) { if (!isNaN(subCount)) {
if (subCount >= 10000) { if (subCount >= 10000) {

View File

@ -516,6 +516,10 @@ Channel:
Your search results have returned 0 results: Your search results have returned 0 Your search results have returned 0 results: Your search results have returned 0
results results
Sort By: Sort By Sort By: Sort By
This channel does not exist: This channel does not exist
This channel does not allow searching: This channel does not allow searching
This channel is age resticted and currently cannot be viewed in FreeTube.: This channel is age resticted and currently cannot be viewed in FreeTube.
Channel Tabs: Channel Tabs
Videos: Videos:
Videos: Videos Videos: Videos
This channel does not currently have any videos: This channel does not currently This channel does not currently have any videos: This channel does not currently
@ -535,6 +539,12 @@ Channel:
About: About:
About: About About: About
Channel Description: Channel Description Channel Description: Channel Description
Tags:
Tags: Tags
Search for: Search for "{tag}"
Details: Details
Joined: Joined
Location: Location
Featured Channels: Featured Channels Featured Channels: Featured Channels
Video: Video:
Mark As Watched: Mark As Watched Mark As Watched: Mark As Watched

View File

@ -1221,11 +1221,6 @@
"@nodelib/fs.scandir" "2.1.5" "@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0" fastq "^1.6.0"
"@protobuf-ts/runtime@^2.7.0":
version "2.8.1"
resolved "https://registry.yarnpkg.com/@protobuf-ts/runtime/-/runtime-2.8.1.tgz#e88f89650ab29c3eba0afebe32b9f3552f35fc85"
integrity sha512-D9M5hSumYCovIfNllt7N6ODh4q+LrjiMWtNETvooaf+a2XheZJ7kgjFlsFghti0CFWwtA//of4JXQfw9hU+cCw==
"@seald-io/binary-search-tree@^1.0.2": "@seald-io/binary-search-tree@^1.0.2":
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/@seald-io/binary-search-tree/-/binary-search-tree-1.0.2.tgz#9f0e5cec5e0acf97f1b495f2f6d3476ddb6a94ed" resolved "https://registry.yarnpkg.com/@seald-io/binary-search-tree/-/binary-search-tree-1.0.2.tgz#9f0e5cec5e0acf97f1b495f2f6d3476ddb6a94ed"
@ -2095,15 +2090,6 @@ aws4@^1.8.0:
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
axios@^1.1.2:
version "1.1.3"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.1.3.tgz#8274250dada2edf53814ed7db644b9c2866c1e35"
integrity sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==
dependencies:
follow-redirects "^1.15.0"
form-data "^4.0.0"
proxy-from-env "^1.1.0"
babel-loader@^9.1.2: babel-loader@^9.1.2:
version "9.1.2" version "9.1.2"
resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-9.1.2.tgz#a16a080de52d08854ee14570469905a5fc00d39c" resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-9.1.2.tgz#a16a080de52d08854ee14570469905a5fc00d39c"
@ -4403,11 +4389,6 @@ follow-redirects@^1.0.0:
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.0.tgz#06441868281c86d0dda4ad8bdaead2d02dca89d4" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.0.tgz#06441868281c86d0dda4ad8bdaead2d02dca89d4"
integrity sha512-aExlJShTV4qOUOL7yF1U5tvLCB0xQuudbf6toyYA0E/acBNw71mvjFTnLaRp50aQaYocMR0a/RMMBIHeZnGyjQ== integrity sha512-aExlJShTV4qOUOL7yF1U5tvLCB0xQuudbf6toyYA0E/acBNw71mvjFTnLaRp50aQaYocMR0a/RMMBIHeZnGyjQ==
follow-redirects@^1.15.0:
version "1.15.2"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
for-each@^0.3.3: for-each@^0.3.3:
version "0.3.3" version "0.3.3"
resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"
@ -5530,10 +5511,10 @@ jest-worker@^29.1.2:
merge-stream "^2.0.0" merge-stream "^2.0.0"
supports-color "^8.0.0" supports-color "^8.0.0"
jintr@^0.3.1: jintr@^0.4.1:
version "0.3.1" version "0.4.1"
resolved "https://registry.yarnpkg.com/jintr/-/jintr-0.3.1.tgz#0ab49390a187d77dc5f2c19580c644d70a94528a" resolved "https://registry.yarnpkg.com/jintr/-/jintr-0.4.1.tgz#df61dd341e08ea619cf80a955be3085059eddeb7"
integrity sha512-AUcq8fKL4BE9jDx8TizZmJ9UOvk1CHKFW0nQcWaOaqk9tkLS9S10fNmusTWGEYTncn7U43nXrCbhYko/ylqrSg== integrity sha512-R42VuIoTjsGbZuEmtT7WqyErd9JQuuV17Cg05wQwRWkQbmQNm2zO519Af1Ib7P7SBATqSMbhyu2/VcTnb3TcOg==
dependencies: dependencies:
acorn "^8.8.0" acorn "^8.8.0"
@ -7047,11 +7028,6 @@ proxy-addr@~2.0.7:
forwarded "0.2.0" forwarded "0.2.0"
ipaddr.js "1.9.1" ipaddr.js "1.9.1"
proxy-from-env@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
pseudomap@^1.0.2: pseudomap@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
@ -8524,7 +8500,7 @@ underscore@1.13.1:
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.1.tgz#0c1c6bd2df54b6b69f2314066d65b6cde6fcf9d1" resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.1.tgz#0c1c6bd2df54b6b69f2314066d65b6cde6fcf9d1"
integrity sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g== integrity sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==
undici@^5.7.0: undici@^5.19.1:
version "5.19.1" version "5.19.1"
resolved "https://registry.yarnpkg.com/undici/-/undici-5.19.1.tgz#92b1fd3ab2c089b5a6bd3e579dcda8f1934ebf6d" resolved "https://registry.yarnpkg.com/undici/-/undici-5.19.1.tgz#92b1fd3ab2c089b5a6bd3e579dcda8f1934ebf6d"
integrity sha512-YiZ61LPIgY73E7syxCDxxa3LV2yl3sN8spnIuTct60boiiRaE1J8mNWHO8Im2Zi/sFrPusjLlmRPrsyraSqX6A== integrity sha512-YiZ61LPIgY73E7syxCDxxa3LV2yl3sN8spnIuTct60boiiRaE1J8mNWHO8Im2Zi/sFrPusjLlmRPrsyraSqX6A==
@ -9176,19 +9152,11 @@ yocto-queue@^0.1.0:
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
youtubei.js@^2.9.0: youtubei.js@^3.1.0:
version "2.9.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-2.9.0.tgz#17426dfb0555169cddede509d50d3db62c102270" resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-3.1.0.tgz#134169fc45aa4cdfc6f28b2071a38baac834c50b"
integrity sha512-paxfeQGwxGw0oPeKdC96jNalS0OnYQ5xdJY27k3J+vamzVcwX6Ky+idALW6Ej9aUC7FISbchBsEVg0Wa7wgGyA== integrity sha512-eVklZqdg2DRon40srC2uMw8z67Bv3qT3vgfiTO9crqRVV2phirGXq0RM6vxmovW3lDIJR0jK67M8j69OvK1BkA==
dependencies: dependencies:
"@protobuf-ts/runtime" "^2.7.0" jintr "^0.4.1"
jintr "^0.3.1"
linkedom "^0.14.12" linkedom "^0.14.12"
undici "^5.7.0" undici "^5.19.1"
yt-channel-info@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/yt-channel-info/-/yt-channel-info-3.2.1.tgz#7b8d5c335a54edd7f41f2db561ff23dd37f854a5"
integrity sha512-drGySe+MqoYMhZzkJpapG5pCfAEBSsCaOZXDzZz4nfQfYhXQGUU11IJ9HpDZmnari1vEWrUasjeu2hwZujZYmw==
dependencies:
axios "^1.1.2"