mirror of https://github.com/FreeTubeApp/FreeTube
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:
parent
662ab372f5
commit
291aeff1a6
|
@ -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']
|
||||
},
|
||||
|
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/*
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
54
yarn.lock
54
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue