Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate channel related functionality to YouTube.js #3143

Merged
merged 18 commits into from
Mar 1, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "^2.9.0"
},
"devDependencies": {
"@babel/core": "^7.20.12",
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ export default defineComponent({
},

handleYoutubeLink: function (href, { doCreateNewWindow = false } = { }) {
this.getYoutubeUrlInfo(href).then((result) => {
this.getYoutubeUrlInfo({ url: href, resolveChannelUrl: true }).then((result) => {
switch (result.urlType) {
case 'video': {
const { videoId, timestamp, playlistId } = result
Expand Down
43 changes: 25 additions & 18 deletions src/renderer/components/data-settings/data-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Expand Down Expand Up @@ -1038,25 +1038,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 (typeof channel === 'string') {
absidue marked this conversation as resolved.
Show resolved Hide resolved
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 []
}
}
},

/*
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/ft-input/ft-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export default defineComponent({

// Update action button icon according to input
try {
this.getYoutubeUrlInfo(this.inputData).then((result) => {
this.getYoutubeUrlInfo({ url: this.inputData }).then((result) => {
let isYoutubeLink = false

switch (result.urlType) {
Expand Down
5 changes: 2 additions & 3 deletions src/renderer/components/top-nav/top-nav.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export default defineComponent({

clearLocalSearchSuggestionsSession()

this.getYoutubeUrlInfo(query).then((result) => {
this.getYoutubeUrlInfo({ url: query, resolveChannelUrl: true }).then((result) => {
switch (result.urlType) {
case 'video': {
const { videoId, timestamp, playlistId } = result
Expand Down Expand Up @@ -175,11 +175,10 @@ export default defineComponent({
}

case 'channel': {
const { channelId, idType, subPath } = result
const { channelId, subPath } = result

openInternalPath({
path: `/channel/${channelId}/${subPath}`,
query: { idType },
doCreateNewWindow
})
break
Expand Down
188 changes: 148 additions & 40 deletions src/renderer/helpers/api/local.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Innertube } from 'youtubei.js'
import { ChannelError } from 'youtubei.js/dist/src/utils/Utils'
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'
Expand All @@ -7,6 +8,7 @@ import { join } from 'path'

import { PlayerCache } from './PlayerCache'
import {
CHANNEL_HANDLE_REGEX,
extractNumberFromString,
getUserDataPath,
toLocalePublicationString
Expand Down Expand Up @@ -88,7 +90,7 @@ export async function getLocalTrending(location, tab, instance) {

const results = resultsInstance.videos
.filter((video) => video.type === 'Video')
.map(parseListVideo)
.map(parseLocalListVideo)

return {
results,
Expand Down Expand Up @@ -166,6 +168,115 @@ 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 ChannelError) {
result = error.message
} else {
throw error
}
}
return result
}
absidue marked this conversation as resolved.
Show resolved Hide resolved

export async function getLocalChannelVideos(id) {
const channel = await getLocalChannel(id)

if (typeof channel === 'string') {
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 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
*/
Expand Down Expand Up @@ -207,13 +318,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,
Expand All @@ -231,20 +338,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
Expand Down Expand Up @@ -281,29 +382,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)
}
}
}
Expand Down Expand Up @@ -409,7 +488,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}`)
Expand Down Expand Up @@ -543,3 +622,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
}
4 changes: 4 additions & 0 deletions src/renderer/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
faBookmark,
faCheck,
faChevronRight,
faCircleUser,
faClone,
faCommentDots,
faCopy,
Expand Down Expand Up @@ -77,6 +78,7 @@ library.add(
faBookmark,
faCheck,
faChevronRight,
faCircleUser,
faClone,
faCommentDots,
faCopy,
Expand Down
Loading