diff --git a/_scripts/webpack.renderer.config.js b/_scripts/webpack.renderer.config.js
index 659f72c09b964..46f63c9dd4faa 100644
--- a/_scripts/webpack.renderer.config.js
+++ b/_scripts/webpack.renderer.config.js
@@ -134,13 +134,7 @@ const config = {
alias: {
vue$: 'vue/dist/vue.common.js',
- // use the web version of linkedom
- linkedom$: 'linkedom/worker',
-
- // defaults to the prebundled browser version which causes webpack to error with:
- // "Critical dependency: require function is used in a way in which dependencies cannot be statically extracted"
- // webpack likes to bundle the dependencies itself, could really have a better error message though
- 'youtubei.js$': 'youtubei.js/dist/browser.js',
+ 'youtubei.js$': 'youtubei.js/web',
},
extensions: ['.js', '.vue']
},
diff --git a/_scripts/webpack.web.config.js b/_scripts/webpack.web.config.js
index 7fa65540427b3..1c494e5c3dd96 100644
--- a/_scripts/webpack.web.config.js
+++ b/_scripts/webpack.web.config.js
@@ -22,17 +22,10 @@ const config = {
path: path.join(__dirname, '../dist/web'),
filename: '[name].js',
},
- externals: [
- {
- electron: '{}'
- },
- ({ request }, callback) => {
- if (request.startsWith('youtubei.js')) {
- return callback(null, '{}')
- }
- callback()
- }
- ],
+ externals: {
+ electron: '{}',
+ 'youtubei.js': '{}'
+ },
module: {
rules: [
{
diff --git a/package.json b/package.json
index 9fad46bf4a659..192c9e1c133ca 100644
--- a/package.json
+++ b/package.json
@@ -75,8 +75,7 @@
"vue-observe-visibility": "^1.0.0",
"vue-router": "^3.6.5",
"vuex": "^3.6.2",
- "youtubei.js": "^2.9.0",
- "yt-channel-info": "^3.2.1"
+ "youtubei.js": "^3.1.0"
},
"devDependencies": {
"@babel/core": "^7.20.12",
diff --git a/src/renderer/App.js b/src/renderer/App.js
index bfe3f74e8bb24..5686882e2a04c 100644
--- a/src/renderer/App.js
+++ b/src/renderer/App.js
@@ -430,11 +430,14 @@ export default defineComponent({
}
case 'channel': {
- const { channelId, subPath } = result
+ const { channelId, subPath, url } = result
openInternalPath({
path: `/channel/${channelId}/${subPath}`,
- doCreateNewWindow
+ doCreateNewWindow,
+ query: {
+ url
+ }
})
break
}
diff --git a/src/renderer/components/data-settings/data-settings.js b/src/renderer/components/data-settings/data-settings.js
index 9e7af7cb41aec..777fe4da92767 100644
--- a/src/renderer/components/data-settings/data-settings.js
+++ b/src/renderer/components/data-settings/data-settings.js
@@ -6,7 +6,6 @@ import FtFlexBox from '../ft-flex-box/ft-flex-box.vue'
import FtPrompt from '../ft-prompt/ft-prompt.vue'
import { MAIN_PROFILE_ID } from '../../../constants'
-import ytch from 'yt-channel-info'
import { calculateColorLuminance, getRandomColor } from '../../helpers/colors'
import {
copyToClipboard,
@@ -17,6 +16,7 @@ import {
writeFileFromDialog
} from '../../helpers/utils'
import { invidiousAPICall } from '../../helpers/api/invidious'
+import { getLocalChannel } from '../../helpers/api/local'
export default defineComponent({
name: 'DataSettings',
@@ -967,25 +967,32 @@ export default defineComponent({
})
},
- getChannelInfoLocal: function (channelId) {
- return new Promise((resolve, reject) => {
- ytch.getChannelInfo({ channelId: channelId }).then(async (response) => {
- resolve(response)
- }).catch((err) => {
- console.error(err)
- const errorMessage = this.$t('Local API Error (Click to copy)')
- showToast(`${errorMessage}: ${err}`, 10000, () => {
- copyToClipboard(err)
- })
+ getChannelInfoLocal: async function (channelId) {
+ try {
+ const channel = await getLocalChannel(channelId)
- if (this.backendFallback && this.backendPreference === 'local') {
- showToast(this.$t('Falling back to the Invidious API'))
- resolve(this.getChannelInfoInvidious(channelId))
- } else {
- resolve([])
- }
+ if (channel.alert) {
+ return undefined
+ }
+
+ return {
+ author: channel.header.author.name,
+ authorThumbnails: channel.header.author.thumbnails
+ }
+ } catch (err) {
+ console.error(err)
+ const errorMessage = this.$t('Local API Error (Click to copy)')
+ showToast(`${errorMessage}: ${err}`, 10000, () => {
+ copyToClipboard(err)
})
- })
+
+ if (this.backendFallback && this.backendPreference === 'local') {
+ showToast(this.$t('Falling back to the Invidious API'))
+ return await this.getChannelInfoInvidious(channelId)
+ } else {
+ return []
+ }
+ }
},
/*
diff --git a/src/renderer/components/top-nav/top-nav.js b/src/renderer/components/top-nav/top-nav.js
index a0e00372643a1..87e2e95803ff4 100644
--- a/src/renderer/components/top-nav/top-nav.js
+++ b/src/renderer/components/top-nav/top-nav.js
@@ -175,12 +175,14 @@ export default defineComponent({
}
case 'channel': {
- const { channelId, idType, subPath } = result
+ const { channelId, subPath, url } = result
openInternalPath({
path: `/channel/${channelId}/${subPath}`,
- query: { idType },
- doCreateNewWindow
+ doCreateNewWindow,
+ query: {
+ url
+ }
})
break
}
diff --git a/src/renderer/helpers/api/invidious.js b/src/renderer/helpers/api/invidious.js
index cf13d97459fe1..784d764d308db 100644
--- a/src/renderer/helpers/api/invidious.js
+++ b/src/renderer/helpers/api/invidious.js
@@ -6,7 +6,7 @@ function getCurrentInstance() {
return store.getters.getCurrentInvidiousInstance
}
-export function invidiousAPICall({ resource, id = '', params = {} }) {
+export function invidiousAPICall({ resource, id = '', params = {}, doLogError = true }) {
return new Promise((resolve, reject) => {
const requestUrl = getCurrentInstance() + '/api/v1/' + resource + '/' + id + '?' + new URLSearchParams(params).toString()
@@ -19,12 +19,39 @@ export function invidiousAPICall({ resource, id = '', params = {} }) {
resolve(json)
})
.catch((error) => {
- console.error('Invidious API error', requestUrl, error)
+ if (doLogError) {
+ console.error('Invidious API error', requestUrl, error)
+ }
reject(error)
})
})
}
+/**
+ * Gets the channel ID for a channel URL
+ * used to get the ID for channel usernames and handles
+ * @param {string} url
+ */
+export async function invidiousGetChannelId(url) {
+ try {
+ const response = await invidiousAPICall({
+ resource: 'resolveurl',
+ params: {
+ url
+ },
+ doLogError: false
+ })
+
+ if (response.pageType === 'WEB_PAGE_TYPE_CHANNEL') {
+ return response.ucid
+ } else {
+ return null
+ }
+ } catch {
+ return null
+ }
+}
+
export async function invidiousGetChannelInfo(channelId) {
return await invidiousAPICall({
resource: 'channels',
diff --git a/src/renderer/helpers/api/local.js b/src/renderer/helpers/api/local.js
index 6906389dc447e..fb9ad96672f77 100644
--- a/src/renderer/helpers/api/local.js
+++ b/src/renderer/helpers/api/local.js
@@ -1,12 +1,10 @@
-import { Innertube } from 'youtubei.js'
-import { ClientType } from 'youtubei.js/dist/src/core/Session'
-import EmojiRun from 'youtubei.js/dist/src/parser/classes/misc/EmojiRun'
-import Text from 'youtubei.js/dist/src/parser/classes/misc/Text'
+import { Innertube, ClientType, Misc, Utils } from 'youtubei.js'
import Autolinker from 'autolinker'
import { join } from 'path'
import { PlayerCache } from './PlayerCache'
import {
+ CHANNEL_HANDLE_REGEX,
extractNumberFromString,
getUserDataPath,
toLocalePublicationString
@@ -88,7 +86,7 @@ export async function getLocalTrending(location, tab, instance) {
const results = resultsInstance.videos
.filter((video) => video.type === 'Video')
- .map(parseListVideo)
+ .map(parseLocalListVideo)
return {
results,
@@ -166,6 +164,117 @@ function decipherFormats(formats, player) {
}
}
+export async function getLocalChannelId(url) {
+ try {
+ const innertube = await createInnertube()
+
+ // resolveURL throws an error if the URL doesn't exist
+ const navigationEndpoint = await innertube.resolveURL(url)
+
+ if (navigationEndpoint.metadata.page_type === 'WEB_PAGE_TYPE_CHANNEL') {
+ return navigationEndpoint.payload.browseId
+ } else {
+ return null
+ }
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Returns the channel or the channel termination reason
+ * @param {string} id
+ */
+export async function getLocalChannel(id) {
+ const innertube = await createInnertube()
+ let result
+ try {
+ result = await innertube.getChannel(id)
+ } catch (error) {
+ if (error instanceof Utils.ChannelError) {
+ result = {
+ alert: error.message
+ }
+ } else {
+ throw error
+ }
+ }
+ return result
+}
+
+export async function getLocalChannelVideos(id) {
+ const channel = await getLocalChannel(id)
+
+ if (channel.alert) {
+ return null
+ }
+
+ if (!channel.has_videos) {
+ return []
+ }
+
+ const videosTab = await channel.getVideos()
+
+ return parseLocalChannelVideos(videosTab.videos, channel.header.author)
+}
+
+/**
+ * @param {import('youtubei.js/dist/src/parser/classes/Video').default[]} videos
+ * @param {import('youtubei.js/dist/src/parser/classes/misc/Author').default} author
+ */
+export function parseLocalChannelVideos(videos, author) {
+ const parsedVideos = videos.map(parseLocalListVideo)
+
+ // fix empty author info
+ parsedVideos.forEach(video => {
+ video.author = author.name
+ video.authorId = author.id
+ })
+
+ return parsedVideos
+}
+
+/**
+ * @typedef {import('youtubei.js/dist/src/parser/classes/Playlist').default} Playlist
+ * @typedef {import('youtubei.js/dist/src/parser/classes/GridPlaylist').default} GridPlaylist
+ */
+
+/**
+ * @param {Playlist|GridPlaylist} playlist
+ * @param {import('youtubei.js/dist/src/parser/classes/misc/Author').default} author
+ */
+export function parseLocalListPlaylist(playlist, author = undefined) {
+ let channelName
+ let channelId = null
+
+ if (playlist.author) {
+ if (playlist.author instanceof Misc.Text) {
+ channelName = playlist.author.text
+
+ if (author) {
+ channelId = author.id
+ }
+ } else {
+ channelName = playlist.author.name
+ channelId = playlist.author.id
+ }
+ } else {
+ channelName = author.name
+ channelId = author.id
+ }
+
+ return {
+ type: 'playlist',
+ dataSource: 'local',
+ title: playlist.title.text,
+ thumbnail: playlist.thumbnails[0].url,
+ channelName,
+ channelId,
+ playlistId: playlist.id,
+ videoCount: extractNumberFromString(playlist.video_count.text)
+ }
+}
+
/**
* @param {Search} response
*/
@@ -207,13 +316,9 @@ export function parseLocalPlaylistVideo(video) {
}
/**
- * @typedef {import('youtubei.js/dist/src/parser/classes/Video').default} Video
+ * @param {import('youtubei.js/dist/src/parser/classes/Video').default} video
*/
-
-/**
- * @param {Video} video
- */
-function parseListVideo(video) {
+export function parseLocalListVideo(video) {
return {
type: 'video',
videoId: video.id,
@@ -231,20 +336,14 @@ function parseListVideo(video) {
}
/**
- * @typedef {import('youtubei.js/dist/src/parser/helpers').YTNode} YTNode
- * @typedef {import('youtubei.js/dist/src/parser/classes/Channel').default} Channel
- * @typedef {import('youtubei.js/dist/src/parser/classes/Playlist').default} Playlist
- */
-
-/**
- * @param {YTNode} item
+ * @param {import('youtubei.js/dist/src/parser/helpers').YTNode} item
*/
function parseListItem(item) {
switch (item.type) {
case 'Video':
- return parseListVideo(item)
+ return parseLocalListVideo(item)
case 'Channel': {
- /** @type {Channel} */
+ /** @type {import('youtubei.js/dist/src/parser/classes/Channel').default} */
const channel = item
// see upstream TODO: https://github.com/LuanRT/YouTube.js/blob/main/src/parser/classes/Channel.ts#L33
@@ -281,29 +380,7 @@ function parseListItem(item) {
}
}
case 'Playlist': {
- /** @type {Playlist} */
- const playlist = item
-
- let channelName
- let channelId = null
-
- if (playlist.author instanceof Text) {
- channelName = playlist.author.text
- } else {
- channelName = playlist.author.name
- channelId = playlist.author.id
- }
-
- return {
- type: 'playlist',
- dataSource: 'local',
- title: playlist.title,
- thumbnail: playlist.thumbnails[0].url,
- channelName,
- channelId,
- playlistId: playlist.id,
- videoCount: extractNumberFromString(playlist.video_count.text)
- }
+ return parseLocalListPlaylist(item)
}
}
}
@@ -359,6 +436,7 @@ function convertSearchFilters(filters) {
/**
* @typedef {import('youtubei.js/dist/src/parser/classes/misc/TextRun').default} TextRun
+ * @typedef {import('youtubei.js/dist/src/parser/classes/misc/EmojiRun').default} EmojiRun
*/
/**
@@ -374,7 +452,7 @@ export function parseLocalTextRuns(runs, emojiSize = 16) {
const parsedRuns = []
for (const run of runs) {
- if (run instanceof EmojiRun) {
+ if (run instanceof Misc.EmojiRun) {
const { emoji, text } = run
// empty array if video creator removes a channel emoji so we ignore.
@@ -413,7 +491,7 @@ export function parseLocalTextRuns(runs, emojiSize = 16) {
break
case 'WEB_PAGE_TYPE_CHANNEL': {
const trimmedText = text.trim()
- if (trimmedText.startsWith('@')) {
+ if (CHANNEL_HANDLE_REGEX.test(trimmedText)) {
parsedRuns.push(`${trimmedText}`)
} else {
parsedRuns.push(`https://www.youtube.com${endpoint.metadata.url}`)
@@ -548,3 +626,32 @@ export function filterFormats(formats, allowAv1 = false) {
return [...audioFormats, ...h264Formats]
}
}
+
+/**
+ * Really not a fan of this :(, YouTube returns the subscribers as "15.1M subscribers"
+ * so we have to parse it somehow
+ * @param {string} text
+ */
+export function parseLocalSubscriberCount(text) {
+ const match = text
+ .replace(',', '.')
+ .toUpperCase()
+ .match(/([\d.]+)\s*([KM]?)/)
+
+ let subscribers
+ if (match) {
+ subscribers = parseFloat(match[1])
+
+ if (match[2] === 'K') {
+ subscribers *= 1000
+ } else if (match[2] === 'M') {
+ subscribers *= 1000_000
+ }
+
+ subscribers = Math.trunc(subscribers)
+ } else {
+ subscribers = extractNumberFromString(text)
+ }
+
+ return subscribers
+}
diff --git a/src/renderer/helpers/utils.js b/src/renderer/helpers/utils.js
index e8254b67e1b6c..43f6ef5bc8d5a 100644
--- a/src/renderer/helpers/utils.js
+++ b/src/renderer/helpers/utils.js
@@ -5,6 +5,10 @@ import FtToastEvents from '../components/ft-toast/ft-toast-events'
import i18n from '../i18n/index'
import router from '../router/index'
+// allowed characters in channel handle: A-Z, a-z, 0-9, -, _, .
+// https://support.google.com/youtube/answer/11585688#change_handle
+export const CHANNEL_HANDLE_REGEX = /^@[\w.-]{3,30}$/
+
export function calculatePublishedDate(publishedText) {
const date = new Date()
if (publishedText === 'Live') {
diff --git a/src/renderer/main.js b/src/renderer/main.js
index dd153e5fd2c23..2aedf2dc61b5e 100644
--- a/src/renderer/main.js
+++ b/src/renderer/main.js
@@ -14,6 +14,7 @@ import {
faBookmark,
faCheck,
faChevronRight,
+ faCircleUser,
faClone,
faCommentDots,
faCopy,
@@ -77,6 +78,7 @@ library.add(
faBookmark,
faCheck,
faChevronRight,
+ faCircleUser,
faClone,
faCommentDots,
faCopy,
diff --git a/src/renderer/store/modules/utils.js b/src/renderer/store/modules/utils.js
index 81d7e49fb674e..97e062b4e05d7 100644
--- a/src/renderer/store/modules/utils.js
+++ b/src/renderer/store/modules/utils.js
@@ -5,6 +5,7 @@ import i18n from '../../i18n/index'
import { IpcChannels } from '../../../constants'
import { pathExists } from '../../helpers/filesystem'
import {
+ CHANNEL_HANDLE_REGEX,
createWebURL,
getVideoParamsFromUrl,
openExternalLink,
@@ -261,7 +262,7 @@ const actions = {
commit('setRegionValues', regionValues)
},
- getYoutubeUrlInfo ({ state }, urlStr) {
+ async getYoutubeUrlInfo({ rootState, state }, urlStr) {
// Returns
// - urlType [String] `video`, `playlist`
//
@@ -288,6 +289,11 @@ const actions = {
//
// If `urlType` is "invalid_url"
// Nothing else
+
+ if (CHANNEL_HANDLE_REGEX.test(urlStr)) {
+ urlStr = `https://www.youtube.com/${urlStr}`
+ }
+
const { videoId, timestamp, playlistId } = getVideoParamsFromUrl(urlStr)
if (videoId) {
return {
@@ -309,7 +315,7 @@ const actions = {
let urlType = 'unknown'
const channelPattern =
- /^\/(?:(?channel|user|c)\/)?(?[^/]+)(?:\/(join|featured|videos|playlists|about|community|channels))?\/?$/
+ /^\/(?:(?:channel|user|c)\/)?(?[^/]+)(?:\/(join|featured|videos|playlists|about|community|channels))?\/?$/
const typePatterns = new Map([
['playlist', /^(\/playlist\/?|\/embed(\/?videoseries)?)$/],
@@ -409,7 +415,6 @@ const actions = {
case 'channel': {
const match = url.pathname.match(channelPattern)
const channelId = match.groups.channelId
- const idType = ['channel', 'user', 'c'].indexOf(match.groups.type) + 1
if (!channelId) {
throw new Error('Channel: could not extract id')
}
@@ -431,8 +436,8 @@ const actions = {
return {
urlType: 'channel',
channelId,
- idType,
- subPath
+ subPath,
+ url: url.toString()
}
}
diff --git a/src/renderer/views/Channel/Channel.css b/src/renderer/views/Channel/Channel.css
index 30e4751a6557f..ffaa72ab59a2c 100644
--- a/src/renderer/views/Channel/Channel.css
+++ b/src/renderer/views/Channel/Channel.css
@@ -18,6 +18,7 @@
}
.channelBannerContainer.default {
+ background-color: black;
background-image: url("../../assets/img/defaultBanner.png");
background-repeat: repeat;
background-size: contain;
@@ -37,11 +38,16 @@
justify-content: space-between;
}
+.channelInfoHasError {
+ padding-bottom: 10px;
+}
+
.channelThumbnail {
width: 100px;
height: 100px;
border-radius: 200px 200px 200px 200px;
-webkit-border-radius: 200px 200px 200px 200px;
+ object-fit: cover;
}
.channelName {
@@ -58,7 +64,7 @@
.channelInfoActionsContainer {
display: flex;
- min-width: 230px;
+ gap: 30px;
justify-content: space-between;
}
@@ -137,6 +143,36 @@
white-space: pre-wrap;
}
+.aboutTags {
+ display: flex;
+ flex-flow: row wrap;
+ gap: 5px 15px;
+ justify-content: center;
+ margin: 0;
+ padding: 0;
+}
+
+.aboutTag {
+ display: flex;
+ list-style: none;
+}
+
+.aboutTagLink {
+ background-color: var(--secondary-card-bg-color);
+ border-radius: 7px;
+ color: inherit;
+ padding: 7px;
+ text-decoration: none;
+}
+
+.aboutDetails {
+ text-align: left;
+}
+
+.aboutDetails th {
+ padding-right: 10px;
+}
+
.channelSearch {
margin-top: 10px;
max-width: 250px;
diff --git a/src/renderer/views/Channel/Channel.js b/src/renderer/views/Channel/Channel.js
index 778b83bfb1025..e94934dc88fea 100644
--- a/src/renderer/views/Channel/Channel.js
+++ b/src/renderer/views/Channel/Channel.js
@@ -11,15 +11,27 @@ import FtElementList from '../../components/ft-element-list/ft-element-list.vue'
import FtAgeRestricted from '../../components/ft-age-restricted/ft-age-restricted.vue'
import FtShareButton from '../../components/ft-share-button/ft-share-button.vue'
-import ytch from 'yt-channel-info'
import autolinker from 'autolinker'
import { MAIN_PROFILE_ID } from '../../../constants'
-import { copyToClipboard, formatNumber, showToast } from '../../helpers/utils'
+import { copyToClipboard, extractNumberFromString, formatNumber, showToast } from '../../helpers/utils'
import packageDetails from '../../../../package.json'
-import { invidiousAPICall, invidiousGetChannelInfo, youtubeImageUrlToInvidious } from '../../helpers/api/invidious'
+import {
+ invidiousAPICall,
+ invidiousGetChannelId,
+ invidiousGetChannelInfo,
+ youtubeImageUrlToInvidious
+} from '../../helpers/api/invidious'
+import {
+ getLocalChannel,
+ getLocalChannelId,
+ parseLocalChannelVideos,
+ parseLocalListPlaylist,
+ parseLocalListVideo,
+ parseLocalSubscriberCount
+} from '../../helpers/api/local'
export default defineComponent({
- name: 'Search',
+ name: 'Channel',
components: {
'ft-card': FtCard,
'ft-button': FtButton,
@@ -38,18 +50,22 @@ export default defineComponent({
isElementListLoading: false,
currentTab: 'videos',
id: '',
- idType: 0,
+ channelInstance: null,
channelName: '',
bannerUrl: '',
thumbnailUrl: '',
subCount: 0,
searchPage: 2,
- videoContinuationString: '',
- playlistContinuationString: '',
- searchContinuationString: '',
- channelDescription: '',
+ videoContinuationData: null,
+ playlistContinuationData: null,
+ searchContinuationData: null,
+ description: '',
+ tags: [],
+ views: 0,
+ joined: 0,
+ location: null,
videoSortBy: 'newest',
- playlistSortBy: 'last',
+ playlistSortBy: 'newest',
lastSearchQuery: '',
relatedChannels: [],
latestVideos: [],
@@ -59,14 +75,15 @@ export default defineComponent({
apiUsed: '',
isFamilyFriendly: false,
errorMessage: '',
+ showSearchBar: true,
+ showShareMenu: true,
videoSelectValues: [
'newest',
- 'oldest',
'popular'
],
playlistSelectValues: [
- 'last',
- 'newest'
+ 'newest',
+ 'last'
],
tabInfoValues: [
'videos',
@@ -100,6 +117,10 @@ export default defineComponent({
return this.$store.getters.getSessionSearchHistory
},
+ currentLocale: function () {
+ return this.$i18n.locale.replace('_', '-')
+ },
+
profileList: function () {
return this.$store.getters.getProfileList
},
@@ -129,15 +150,14 @@ export default defineComponent({
videoSelectNames: function () {
return [
this.$t('Channel.Videos.Sort Types.Newest'),
- this.$t('Channel.Videos.Sort Types.Oldest'),
this.$t('Channel.Videos.Sort Types.Most Popular')
]
},
playlistSelectNames: function () {
return [
- this.$t('Channel.Playlists.Sort Types.Last Video Added'),
- this.$t('Channel.Playlists.Sort Types.Newest')
+ this.$t('Channel.Playlists.Sort Types.Newest'),
+ this.$t('Channel.Playlists.Sort Types.Last Video Added')
]
},
@@ -148,20 +168,28 @@ export default defineComponent({
return formatNumber(this.subCount)
},
+ formattedViews: function () {
+ return formatNumber(this.views)
+ },
+
+ formattedJoined: function () {
+ return new Intl.DateTimeFormat([this.currentLocale, 'en'], { dateStyle: 'long' }).format(this.joined)
+ },
+
showFetchMoreButton: function () {
switch (this.currentTab) {
case 'videos':
- if (this.apiUsed === 'invidious' || (this.videoContinuationString !== '' && this.videoContinuationString !== null)) {
+ if (this.videoContinuationData !== null) {
return true
}
break
case 'playlists':
- if (this.playlistContinuationString !== '' && this.playlistContinuationString !== null) {
+ if (this.playlistContinuationData !== null) {
return true
}
break
case 'search':
- if (this.searchContinuationString !== '' && this.searchContinuationString !== null) {
+ if (this.searchContinuationData !== null) {
return true
}
break
@@ -171,14 +199,31 @@ export default defineComponent({
},
hideChannelSubscriptions: function () {
return this.$store.getters.getHideChannelSubscriptions
+ },
+
+ searchSettings: function () {
+ return this.$store.getters.getSearchSettings
+ },
+
+ hideSearchBar: function () {
+ return this.$store.getters.getHideSearchBar
+ },
+
+ hideSharingActions: function () {
+ return this.$store.getters.getHideSharingActions
}
},
watch: {
$route() {
// react to route changes...
- this.originalId = this.$route.params.id
+ this.isLoading = true
+
+ if (this.$route.query.url) {
+ this.resolveChannelUrl(this.$route.query.url, this.$route.params.currentTab)
+ return
+ }
+
this.id = this.$route.params.id
- this.idType = this.$route.query.idType ? Number(this.$route.query.idType) : 0
this.currentTab = this.$route.params.currentTab ?? 'videos'
this.searchPage = 2
this.relatedChannels = []
@@ -187,15 +232,25 @@ export default defineComponent({
this.searchResults = []
this.shownElementList = []
this.apiUsed = ''
- this.isLoading = true
+ this.channelInstance = ''
+ this.videoContinuationData = null
+ this.playlistContinuationData = null
+ this.searchContinuationData = null
+ this.showSearchBar = true
+
+ if (this.id === '@@@') {
+ this.showShareMenu = false
+ this.setErrorMessage(this.$i18n.t('Channel.This channel does not exist'))
+ return
+ }
+
+ this.showShareMenu = true
+ this.errorMessage = ''
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
this.getChannelInfoInvidious()
- this.getPlaylistsInvidious()
} else {
- this.getChannelInfoLocal()
- this.getChannelVideosLocal()
- this.getPlaylistsLocal()
+ this.getChannelLocal()
}
},
@@ -207,7 +262,7 @@ export default defineComponent({
this.getChannelVideosLocal()
break
case 'invidious':
- this.channelInvidiousVideos()
+ this.channelInvidiousVideos(true)
break
default:
this.getChannelVideosLocal()
@@ -217,95 +272,247 @@ export default defineComponent({
playlistSortBy () {
this.isElementListLoading = true
this.latestPlaylists = []
- this.playlistContinuationString = ''
+ this.playlistContinuationData = null
switch (this.apiUsed) {
case 'local':
- this.getPlaylistsLocal()
+ this.getChannelPlaylistsLocal()
break
case 'invidious':
this.getPlaylistsInvidious()
break
default:
- this.getPlaylistsLocal()
+ this.getChannelPlaylistsLocal()
}
}
},
mounted: function () {
- this.originalId = this.$route.params.id
+ this.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.isLoading = true
+
+ 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)
+ let channelId
+ let subscriberText = null
+ let tags = []
+
+ switch (channel.header.type) {
+ case 'C4TabbedHeader': {
+ // example: Linus Tech Tips
+ // https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw
+
+ /**
+ * @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.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
- })
+ this.isFamilyFriendly = !!channel.metadata.is_family_safe
+
+ if (channel.metadata.tags) {
+ tags.push(...channel.metadata.tags)
+ }
- if (response.authorBanners !== null) {
- const bannerUrl = response.authorBanners[response.authorBanners.length - 1].url
+ // 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 (!bannerUrl.includes('https')) {
- this.bannerUrl = `https://${bannerUrl}`
+ if (isNaN(subCount)) {
+ this.subCount = null
} else {
- this.bannerUrl = bannerUrl
+ 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
}
- })
+ }
+ },
+
+ 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: function () {
+ getChannelVideosLocal: async function () {
this.isElementListLoading = true
- const expectedId = this.originalId
- ytch.getChannelVideos({ channelId: this.id, channelIdType: this.idType, sortBy: this.videoSortBy }).then((response) => {
- if (expectedId !== this.originalId) {
+ const expectedId = this.id
+
+ try {
+ /**
+ * @type {import('youtubei.js/dist/src/parser/youtube/Channel').default}
+ */
+ const channel = this.channelInstance
+ let videosTab = await channel.getVideos()
+
+ if (this.videoSortBy !== 'newest') {
+ const index = this.videoSelectValues.indexOf(this.videoSortBy)
+ videosTab = await videosTab.applyFilter(videosTab.filters[index])
+ }
+
+ if (expectedId !== this.id) {
return
}
- this.latestVideos = response.items
- this.videoContinuationString = response.continuation
+ this.latestVideos = parseLocalChannelVideos(videosTab.videos, channel.header.author)
+ this.videoContinuationData = videosTab.has_continuation ? videosTab : null
this.isElementListLoading = false
- }).catch((err) => {
+ } catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
@@ -343,29 +593,35 @@ export default defineComponent({
} else {
this.isLoading = false
}
- })
+ }
},
- channelLocalNextPage: function () {
- ytch.getChannelVideosMore({ continuation: this.videoContinuationString }).then((response) => {
- this.latestVideos = this.latestVideos.concat(response.items)
- this.videoContinuationString = response.continuation
- }).catch((err) => {
+ channelLocalNextPage: async function () {
+ try {
+ /**
+ * @type {import('youtubei.js/dist/src/parser/youtube/Channel').ChannelListContinuation|import('youtubei.js/dist/src/parser/youtube/Channel').FilteredChannelList}
+ */
+ const continuation = await this.videoContinuationData.getContinuation()
+
+ this.latestVideos = this.latestVideos.concat(parseLocalChannelVideos(continuation.videos, this.channelInstance.header.author))
+ this.videoContinuationData = continuation.has_continuation ? continuation : null
+ } catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
- })
+ }
},
getChannelInfoInvidious: function () {
this.isLoading = true
this.apiUsed = 'invidious'
+ this.channelInstance = null
- const expectedId = this.originalId
+ const expectedId = this.id
invidiousGetChannelInfo(this.id).then((response) => {
- if (expectedId !== this.originalId) {
+ if (expectedId !== this.id) {
return
}
@@ -383,14 +639,16 @@ export default defineComponent({
const thumbnail = response.authorThumbnails[3].url
this.thumbnailUrl = youtubeImageUrlToInvidious(thumbnail, this.currentInvidiousInstance)
this.updateSubscriptionDetails({ channelThumbnailUrl: thumbnail, channelName: channelName, channelId: channelId })
- this.channelDescription = autolinker.link(response.description)
+ this.description = autolinker.link(response.description)
+ this.views = response.totalViews
+ this.joined = new Date(response.joined * 1000)
this.relatedChannels = response.relatedChannels.map((channel) => {
- channel.authorThumbnails = channel.authorThumbnails.map(thumbnail => {
- thumbnail.url = youtubeImageUrlToInvidious(thumbnail.url, this.currentInvidiousInstance)
- return thumbnail
- })
- channel.channelId = channel.authorId
- return channel
+ const thumbnailUrl = channel.authorThumbnails.at(-1).url
+ return {
+ name: channel.author,
+ id: channel.authorId,
+ thumbnailUrl: youtubeImageUrlToInvidious(thumbnailUrl, this.currentInvidiousInstance)
+ }
})
this.latestVideos = response.latestVideos
@@ -401,19 +659,36 @@ export default defineComponent({
}
this.errorMessage = ''
+
+ // some channels only have a few tabs
+ // here are all possible values: home, videos, shorts, streams, playlists, community, channels, about
+
+ if (response.tabs.includes('videos')) {
+ this.channelInvidiousVideos()
+ }
+
+ if (response.tabs.includes('playlists')) {
+ this.getPlaylistsInvidious()
+ }
+
this.isLoading = false
}).catch((err) => {
- this.setErrorMessage(err.responseJSON.error)
+ this.setErrorMessage(err)
console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
- showToast(`${errorMessage}: ${err.responseJSON.error}`, 10000, () => {
- copyToClipboard(err.responseJSON.error)
+ showToast(`${errorMessage}: ${err}`, 10000, () => {
+ copyToClipboard(err)
})
- this.isLoading = false
+ if (process.env.IS_ELECTRON && this.backendPreference === 'invidious' && this.backendFallback) {
+ showToast(this.$t('Falling back to Local API'))
+ this.getChannelLocal()
+ } else {
+ this.isLoading = false
+ }
})
},
- channelInvidiousVideos: function (fetchMore) {
+ channelInvidiousVideos: function (sortByChanged) {
const payload = {
resource: 'channels/videos',
id: this.id,
@@ -421,11 +696,28 @@ export default defineComponent({
sort_by: this.videoSortBy,
}
}
- if (fetchMore) payload.params.continuation = this.videoContinuationString
+
+ if (sortByChanged) {
+ this.videoContinuationData = null
+ }
+
+ let more = false
+ if (this.videoContinuationData) {
+ payload.params.continuation = this.videoContinuationData
+ more = true
+ }
+
+ if (!more) {
+ this.isElementListLoading = true
+ }
invidiousAPICall(payload).then((response) => {
- this.latestVideos = this.latestVideos.concat(response.videos)
- this.videoContinuationString = response.continuation
+ if (more) {
+ this.latestVideos = this.latestVideos.concat(response.videos)
+ } else {
+ this.latestVideos = response.videos
+ }
+ this.videoContinuationData = response.continuation || null
this.isElementListLoading = false
}).catch((err) => {
console.error(err)
@@ -436,20 +728,45 @@ export default defineComponent({
})
},
- getPlaylistsLocal: function () {
- const expectedId = this.originalId
- ytch.getChannelPlaylistInfo({ channelId: this.id, channelIdType: this.idType, sortBy: this.playlistSortBy }).then((response) => {
- if (expectedId !== this.originalId) {
+ getChannelPlaylistsLocal: async function () {
+ const expectedId = this.id
+
+ try {
+ /**
+ * @type {import('youtubei.js/dist/src/parser/youtube/Channel').default}
+ */
+ const channel = this.channelInstance
+ let playlistsTab = await channel.getPlaylists()
+
+ // some channels have more categories of playlists than just "Created Playlists" e.g. https://www.youtube.com/channel/UCez-2shYlHQY3LfILBuDYqQ
+ // for the moment we just want the "Created Playlists" category that has all playlists in it
+
+ if (playlistsTab.content_type_filters.length > 1) {
+ /**
+ * @type {import('youtubei.js/dist/src/parser/classes/ChannelSubMenu').default}
+ */
+ const menu = playlistsTab.current_tab.content.sub_menu
+ const createdPlaylistsFilter = menu.content_type_sub_menu_items.find(contentType => {
+ const url = `https://youtube.com/${contentType.endpoint.metadata.url}`
+ return new URL(url).searchParams.get('view') === '1'
+ }).title
+
+ playlistsTab = await playlistsTab.applyContentTypeFilter(createdPlaylistsFilter)
+ }
+
+ if (this.playlistSortBy !== 'newest' && playlistsTab.sort_filters.length > 0) {
+ const index = this.playlistSelectValues.indexOf(this.playlistSortBy)
+ playlistsTab = await playlistsTab.applySort(playlistsTab.sort_filters[index])
+ }
+
+ if (expectedId !== this.id) {
return
}
- this.latestPlaylists = response.items.map((item) => {
- item.proxyThumbnail = false
- return item
- })
- this.playlistContinuationString = response.continuation
+ this.latestPlaylists = playlistsTab.playlists.map(playlist => parseLocalListPlaylist(playlist, channel.header.author))
+ this.playlistContinuationData = playlistsTab.has_continuation ? playlistsTab : null
this.isElementListLoading = false
- }).catch((err) => {
+ } catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
@@ -461,23 +778,30 @@ export default defineComponent({
} else {
this.isLoading = false
}
- })
+ }
},
- getPlaylistsLocalMore: function () {
- ytch.getChannelPlaylistsMore({ continuation: this.playlistContinuationString }).then((response) => {
- this.latestPlaylists = this.latestPlaylists.concat(response.items)
- this.playlistContinuationString = response.continuation
- }).catch((err) => {
+ getChannelPlaylistsLocalMore: async function () {
+ try {
+ /**
+ * @type {import('youtubei.js/dist/src/parser/youtube/Channel').ChannelListContinuation}
+ */
+ const continuation = await this.playlistContinuationData.getContinuation()
+
+ const parsedPlaylists = continuation.playlists.map(playlist => parseLocalListPlaylist(playlist, this.channelInstance.header.author))
+ this.latestPlaylists = this.latestPlaylists.concat(parsedPlaylists)
+ this.playlistContinuationData = continuation.has_continuation ? continuation : null
+ } catch (err) {
console.error(err)
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
- })
+ }
},
getPlaylistsInvidious: function () {
+ this.isElementListLoading = true
const payload = {
resource: 'channels/playlists',
id: this.id,
@@ -487,18 +811,18 @@ export default defineComponent({
}
invidiousAPICall(payload).then((response) => {
- this.playlistContinuationString = response.continuation
+ this.playlistContinuationData = response.continuation || null
this.latestPlaylists = response.playlists
this.isElementListLoading = false
}).catch((err) => {
console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
- showToast(`${errorMessage}: ${err.responseJSON.error}`, 10000, () => {
- copyToClipboard(err.responseJSON.error)
+ showToast(`${errorMessage}: ${err}`, 10000, () => {
+ copyToClipboard(err)
})
if (process.env.IS_ELECTRON && this.backendPreference === 'invidious' && this.backendFallback) {
showToast(this.$t('Falling back to Local API'))
- this.getPlaylistsLocal()
+ this.getChannelLocal()
} else {
this.isLoading = false
}
@@ -506,7 +830,7 @@ export default defineComponent({
},
getPlaylistsInvidiousMore: function () {
- if (this.playlistContinuationString === null) {
+ if (this.playlistContinuationData === null) {
console.warn('There are no more playlists available for this channel')
return
}
@@ -519,23 +843,23 @@ export default defineComponent({
}
}
- if (this.playlistContinuationString) {
- payload.params.continuation = this.playlistContinuationString
+ if (this.playlistContinuationData) {
+ payload.params.continuation = this.playlistContinuationData
}
invidiousAPICall(payload).then((response) => {
- this.playlistContinuationString = response.continuation
+ this.playlistContinuationData = response.continuation || null
this.latestPlaylists = this.latestPlaylists.concat(response.playlists)
this.isElementListLoading = false
}).catch((err) => {
console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
- showToast(`${errorMessage}: ${err.responseJSON.error}`, 10000, () => {
- copyToClipboard(err.responseJSON.error)
+ showToast(`${errorMessage}: ${err}`, 10000, () => {
+ copyToClipboard(err)
})
if (process.env.IS_ELECTRON && this.backendPreference === 'invidious' && this.backendFallback) {
showToast(this.$t('Falling back to Local API'))
- this.getPlaylistsLocal()
+ this.getChannelLocal()
} else {
this.isLoading = false
}
@@ -608,12 +932,14 @@ export default defineComponent({
}
},
- setErrorMessage: function (errorMessage) {
+ setErrorMessage: function (errorMessage, responseHasNameAndThumbnail = false) {
this.isLoading = false
this.errorMessage = errorMessage
- this.id = this.subscriptionInfo.id
- this.channelName = this.subscriptionInfo.name
- this.thumbnailUrl = this.subscriptionInfo.thumbnail
+
+ if (!responseHasNameAndThumbnail) {
+ this.channelName = this.subscriptionInfo?.name
+ this.thumbnailUrl = this.subscriptionInfo?.thumbnail
+ }
this.bannerUrl = null
this.subCount = null
},
@@ -626,14 +952,14 @@ export default defineComponent({
this.channelLocalNextPage()
break
case 'invidious':
- this.channelInvidiousVideos(true)
+ this.channelInvidiousVideos()
break
}
break
case 'playlists':
switch (this.apiUsed) {
case 'local':
- this.getPlaylistsLocalMore()
+ this.getChannelPlaylistsLocalMore()
break
case 'invidious':
this.getPlaylistsInvidiousMore()
@@ -687,7 +1013,7 @@ export default defineComponent({
newSearch: function (query) {
this.lastSearchQuery = query
- this.searchContinuationString = ''
+ this.searchContinuationData = null
this.isElementListLoading = true
this.searchPage = 1
this.searchResults = []
@@ -702,37 +1028,58 @@ export default defineComponent({
}
},
- searchChannelLocal: function () {
- if (this.searchContinuationString === '') {
- ytch.searchChannel({ channelId: this.id, channelIdType: this.idType, query: this.lastSearchQuery }).then((response) => {
- this.searchResults = response.items
- this.isElementListLoading = false
- this.searchContinuationString = response.continuation
- }).catch((err) => {
- console.error(err)
- const errorMessage = this.$t('Local API Error (Click to copy)')
- showToast(`${errorMessage}: ${err}`, 10000, () => {
- copyToClipboard(err)
+ searchChannelLocal: async function () {
+ const isNewSearch = this.searchContinuationData === null
+ try {
+ let result
+ let contents
+ if (isNewSearch) {
+ if (!this.channelInstance.has_search) {
+ showToast(this.$t('Channel.This channel does not allow searching'), 5000)
+ this.showSearchBar = false
+ return
+ }
+ result = await this.channelInstance.search(this.lastSearchQuery)
+ contents = result.current_tab.content.contents
+ } else {
+ result = await this.searchContinuationData.getContinuation()
+ contents = result.contents.contents
+ }
+
+ const results = contents
+ .filter(node => node.type === 'ItemSection')
+ .flatMap(itemSection => itemSection.contents)
+ .filter(item => item.type === 'Video' || item.type === 'Playlist')
+ .map(item => {
+ if (item.type === 'Video') {
+ return parseLocalListVideo(item)
+ } else {
+ return parseLocalListPlaylist(item, this.channelInstance.header.author)
+ }
})
+
+ if (isNewSearch) {
+ this.searchResults = results
+ } else {
+ this.searchResults = this.searchResults.concat(results)
+ }
+
+ this.searchContinuationData = result.has_continuation ? result : null
+ this.isElementListLoading = false
+ } catch (err) {
+ console.error(err)
+ const errorMessage = this.$t('Local API Error (Click to copy)')
+ showToast(`${errorMessage}: ${err}`, 10000, () => {
+ copyToClipboard(err)
+ })
+ if (isNewSearch) {
if (this.backendPreference === 'local' && this.backendFallback) {
showToast(this.$t('Falling back to Invidious API'))
this.searchChannelInvidious()
} else {
this.isLoading = false
}
- })
- } else {
- ytch.searchChannelMore({ continuation: this.searchContinuationString }).then((response) => {
- this.searchResults = this.searchResults.concat(response.items)
- this.isElementListLoading = false
- this.searchContinuationString = response.continuation
- }).catch((err) => {
- console.error(err)
- const errorMessage = this.$t('Local API Error (Click to copy)')
- showToast(`${errorMessage}: ${err}`, 10000, () => {
- copyToClipboard(err)
- })
- })
+ }
}
},
diff --git a/src/renderer/views/Channel/Channel.vue b/src/renderer/views/Channel/Channel.vue
index 3d25f9a7ff9f9..fc8d402b7335f 100644
--- a/src/renderer/views/Channel/Channel.vue
+++ b/src/renderer/views/Channel/Channel.vue
@@ -23,15 +23,22 @@
>
![]()
+
@@ -54,13 +61,14 @@
-
+
{{ $t("Channel.About.Channel Description") }}
-
+
+ {{ $t('Channel.About.Details') }}
+
+
+
+ |
+ {{ $t('Channel.About.Joined') }}
+ |
+ {{ formattedJoined }} |
+
+
+ |
+ {{ $t('Video.Views') }}
+ |
+ {{ formattedViews }} |
+
+
+ |
+ {{ $t('Channel.About.Location') }}
+ |
+ {{ location }} |
+
+
+
+ {{ $t('Channel.About.Tags.Tags') }}
+
+
@@ -157,16 +241,16 @@
{
- 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 {
diff --git a/src/renderer/views/Subscriptions/Subscriptions.js b/src/renderer/views/Subscriptions/Subscriptions.js
index 266ecc48fa869..48a896d566c46 100644
--- a/src/renderer/views/Subscriptions/Subscriptions.js
+++ b/src/renderer/views/Subscriptions/Subscriptions.js
@@ -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) {
diff --git a/src/renderer/views/Watch/Watch.js b/src/renderer/views/Watch/Watch.js
index 67f8c1d6ff9e8..3c242d5e73028 100644
--- a/src/renderer/views/Watch/Watch.js
+++ b/src/renderer/views/Watch/Watch.js
@@ -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) {
diff --git a/static/locales/en-US.yaml b/static/locales/en-US.yaml
index 7b5980bbc5d75..b2e2de1824377 100644
--- a/static/locales/en-US.yaml
+++ b/static/locales/en-US.yaml
@@ -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
diff --git a/yarn.lock b/yarn.lock
index 5d3eba31cd263..ba57437676780 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1199,11 +1199,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"
@@ -2073,15 +2068,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"
@@ -4380,11 +4366,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"
@@ -5507,10 +5488,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"
@@ -7024,11 +7005,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"
@@ -8501,7 +8477,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==
@@ -9153,19 +9129,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"