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: {
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']
},

View File

@ -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: [
{

View File

@ -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",

View File

@ -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
}

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 { 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 []
}
}
},
/*

View File

@ -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
}

View File

@ -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',

View File

@ -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(`<a href="https://www.youtube.com/channel/${endpoint.payload.browseId}">${trimmedText}</a>`)
} 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
}

View File

@ -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') {

View File

@ -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,

View File

@ -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 =
/^\/(?:(?<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([
['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()
}
}

View File

@ -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;

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 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)
})
})
}
}
},

View File

@ -23,15 +23,22 @@
>
<div
class="channelInfo"
:class="{ channelInfoHasError: errorMessage }"
>
<div
class="thumbnailContainer"
>
<img
v-if="thumbnailUrl"
class="channelThumbnail"
:src="thumbnailUrl"
alt=""
>
<font-awesome-icon
v-else
class="channelThumbnail"
:icon="['fas', 'circle-user']"
/>
<div
class="channelLineContainer"
>
@ -54,13 +61,14 @@
<div class="channelInfoActionsContainer">
<ft-share-button
v-if="!hideSharingActions && showShareMenu"
:id="id"
share-target-type="Channel"
class="shareIcon"
/>
<ft-button
v-if="!hideUnsubscribeButton"
v-if="!hideUnsubscribeButton && (!errorMessage || isSubscribed)"
:label="subscribedText"
background-color="var(--primary-color)"
text-color="var(--text-with-main-color)"
@ -121,6 +129,7 @@
</div>
<ft-input
v-if="showSearchBar"
:placeholder="$t('Channel.Search Channel')"
:show-clear-text-button="true"
class="channelSearch"
@ -138,14 +147,89 @@
id="aboutPanel"
class="aboutTab"
>
<h2>
<h2
v-if="description"
>
{{ $t("Channel.About.Channel Description") }}
</h2>
<div
v-if="description"
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
v-if="relatedChannels.length > 0"
>
@ -157,16 +241,16 @@
<ft-channel-bubble
v-for="(channel, index) in relatedChannels"
:key="index"
:channel-name="channel.author || channel.channelName"
:channel-id="channel.channelId"
:channel-thumbnail="channel.authorThumbnails[channel.authorThumbnails.length - 1].url"
:channel-name="channel.name"
:channel-id="channel.id"
:channel-thumbnail="channel.thumbnailUrl"
role="link"
@click="goToChannel(channel.channelId)"
@click="goToChannel(channel.id)"
/>
</ft-flex-box>
</div>
<ft-select
v-show="currentTab === 'videos'"
v-show="currentTab === 'videos' && latestVideos.length > 0"
class="sortSelect"
:value="videoSelectValues[0]"
:select-names="videoSelectNames"
@ -175,7 +259,7 @@
@change="videoSortBy = $event"
/>
<ft-select
v-show="currentTab === 'playlists'"
v-show="currentTab === 'playlists' && latestPlaylists.length > 0"
class="sortSelect"
:value="playlistSelectValues[0]"
: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 FtInput from '../../components/ft-input/ft-input.vue'
import FtPrompt from '../../components/ft-prompt/ft-prompt.vue'
import ytch from 'yt-channel-info'
import { showToast } from '../../helpers/utils'
import { invidiousGetChannelInfo, youtubeImageUrlToInvidious, invidiousImageUrlToInvidious } from '../../helpers/api/invidious'
import { getLocalChannel } from '../../helpers/api/local'
export default defineComponent({
name: 'SubscribedChannels',
@ -182,12 +182,14 @@ export default defineComponent({
if (this.backendPreference === 'local') {
// avoid too many concurrent requests
setTimeout(() => {
ytch.getChannelInfo({ channelId: channel.id }).then(response => {
this.updateSubscriptionDetails({
channelThumbnailUrl: this.thumbnailURL(response.authorThumbnails[0].url),
channelName: channel.name,
channelId: channel.id
})
getLocalChannel(channel.id).then(response => {
if (!response.alert) {
this.updateSubscriptionDetails({
channelThumbnailUrl: this.thumbnailURL(response.header.author.thumbnails[0].url),
channelName: channel.name,
channelId: channel.id
})
}
})
}, this.errorCount * 500)
} 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 FtChannelBubble from '../../components/ft-channel-bubble/ft-channel-bubble.vue'
import ytch from 'yt-channel-info'
import { MAIN_PROFILE_ID } from '../../../constants'
import { calculatePublishedDate, copyToClipboard, showToast } from '../../helpers/utils'
import { invidiousAPICall } from '../../helpers/api/invidious'
import { getLocalChannelVideos } from '../../helpers/api/local'
export default defineComponent({
name: 'Subscriptions',
@ -270,50 +270,47 @@ export default defineComponent({
}
},
getChannelVideosLocalScraper: function (channel, failedAttempts = 0) {
return new Promise((resolve, reject) => {
ytch.getChannelVideos({ channelId: channel.id, sortBy: 'latest' }).then((response) => {
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
})
getChannelVideosLocalScraper: async function (channel, failedAttempts = 0) {
try {
const videos = await getLocalChannelVideos(channel.id)
resolve(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:
resolve(this.getChannelVideosLocalRSS(channel, failedAttempts + 1))
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([])
if (videos === null) {
this.errorChannels.push(channel)
return []
}
videos.map(video => {
if (video.liveNow) {
video.publishedDate = new Date().getTime()
} else {
video.publishedDate = calculatePublishedDate(video.publishedText)
}
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) {

View File

@ -15,7 +15,6 @@ import { pathExists } from '../../helpers/filesystem'
import {
buildVTTFileLocally,
copyToClipboard,
extractNumberFromString,
formatDurationAsTimestamp,
formatNumber,
getFormatsFromHLSManifest,
@ -26,6 +25,7 @@ import {
filterFormats,
getLocalVideoInfo,
mapLocalFormat,
parseLocalSubscriberCount,
parseLocalTextRuns,
parseLocalWatchNextVideo
} from '../../helpers/api/local'
@ -336,27 +336,7 @@ export default defineComponent({
this.isLiveContent = !!result.basic_info.is_live_content
if (!this.hideChannelSubscriptions) {
// really not a fan of this :(, YouTube returns the subscribers as "15.1M subscribers"
// 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)
}
const subCount = parseLocalSubscriberCount(result.secondary_info.owner.subscriber_count.text)
if (!isNaN(subCount)) {
if (subCount >= 10000) {

View File

@ -516,6 +516,10 @@ Channel:
Your search results have returned 0 results: Your search results have returned 0
results
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
This channel does not currently have any videos: This channel does not currently
@ -535,6 +539,12 @@ Channel:
About:
About: About
Channel Description: Channel Description
Tags:
Tags: Tags
Search for: Search for "{tag}"
Details: Details
Joined: Joined
Location: Location
Featured Channels: Featured Channels
Video:
Mark As Watched: Mark As Watched

View File

@ -1221,11 +1221,6 @@
"@nodelib/fs.scandir" "2.1.5"
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":
version "1.0.2"
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"
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:
version "9.1.2"
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"
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:
version "0.3.3"
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"
supports-color "^8.0.0"
jintr@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/jintr/-/jintr-0.3.1.tgz#0ab49390a187d77dc5f2c19580c644d70a94528a"
integrity sha512-AUcq8fKL4BE9jDx8TizZmJ9UOvk1CHKFW0nQcWaOaqk9tkLS9S10fNmusTWGEYTncn7U43nXrCbhYko/ylqrSg==
jintr@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/jintr/-/jintr-0.4.1.tgz#df61dd341e08ea619cf80a955be3085059eddeb7"
integrity sha512-R42VuIoTjsGbZuEmtT7WqyErd9JQuuV17Cg05wQwRWkQbmQNm2zO519Af1Ib7P7SBATqSMbhyu2/VcTnb3TcOg==
dependencies:
acorn "^8.8.0"
@ -7047,11 +7028,6 @@ proxy-addr@~2.0.7:
forwarded "0.2.0"
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:
version "1.0.2"
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"
integrity sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==
undici@^5.7.0:
undici@^5.19.1:
version "5.19.1"
resolved "https://registry.yarnpkg.com/undici/-/undici-5.19.1.tgz#92b1fd3ab2c089b5a6bd3e579dcda8f1934ebf6d"
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"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
youtubei.js@^2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-2.9.0.tgz#17426dfb0555169cddede509d50d3db62c102270"
integrity sha512-paxfeQGwxGw0oPeKdC96jNalS0OnYQ5xdJY27k3J+vamzVcwX6Ky+idALW6Ej9aUC7FISbchBsEVg0Wa7wgGyA==
youtubei.js@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-3.1.0.tgz#134169fc45aa4cdfc6f28b2071a38baac834c50b"
integrity sha512-eVklZqdg2DRon40srC2uMw8z67Bv3qT3vgfiTO9crqRVV2phirGXq0RM6vxmovW3lDIJR0jK67M8j69OvK1BkA==
dependencies:
"@protobuf-ts/runtime" "^2.7.0"
jintr "^0.3.1"
jintr "^0.4.1"
linkedom "^0.14.12"
undici "^5.7.0"
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"
undici "^5.19.1"