Migrate comments to YouTube.js (#3072)

* Migrate comments to YouTube.js

* Various improvements
This commit is contained in:
absidue 2023-01-24 03:22:54 +01:00 committed by GitHub
parent cb64004080
commit 0145a0425f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 168 additions and 179 deletions

View File

@ -55,7 +55,6 @@
"@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",
"@fortawesome/vue-fontawesome": "^2.0.9",
"@freetube/yt-comment-scraper": "^6.2.0",
"@silvermine/videojs-quality-selector": "^1.2.5",
"autolinker": "^4.0.0",
"browserify": "^17.0.0",

View File

@ -3,15 +3,9 @@ import FtCard from '../ft-card/ft-card.vue'
import FtLoader from '../../components/ft-loader/ft-loader.vue'
import FtSelect from '../../components/ft-select/ft-select.vue'
import FtTimestampCatcher from '../../components/ft-timestamp-catcher/ft-timestamp-catcher.vue'
import autolinker from 'autolinker'
import ytcm from '@freetube/yt-comment-scraper'
import {
copyToClipboard,
showToast,
stripHTML,
toLocalePublicationString
} from '../../helpers/utils'
import { copyToClipboard, showToast } from '../../helpers/utils'
import { invidiousGetCommentReplies, invidiousGetComments } from '../../helpers/api/invidious'
import { getLocalComments, parseLocalComment } from '../../helpers/api/local'
export default defineComponent({
name: 'WatchVideoComments',
@ -39,12 +33,9 @@ export default defineComponent({
return {
isLoading: false,
showComments: false,
commentScraper: null,
nextPageToken: null,
commentData: [],
sortNewest: false,
commentProcess: null,
sortingChanged: false
sortNewest: false
}
},
computed: {
@ -82,52 +73,23 @@ export default defineComponent({
}
},
beforeDestroy: function () {
if (this.commentProcess !== null) {
this.commentProcess.send('end')
}
},
methods: {
onTimestamp: function (timestamp) {
this.$emit('timestamp-event', timestamp)
},
handleSortChange: function (sortType) {
handleSortChange: function () {
this.sortNewest = !this.sortNewest
switch (this.backendPreference) {
case 'local':
this.isLoading = true
this.commentData = []
this.nextPageToken = undefined
this.getCommentDataLocal({
videoId: this.id,
setCookie: false,
sortByNewest: this.sortNewest,
continuation: this.nextPageToken ? this.nextPageToken : undefined
})
break
case 'invidious':
this.isLoading = true
this.commentData = []
this.getCommentDataInvidious()
break
}
this.commentData = []
this.getCommentData()
},
getCommentData: function () {
this.isLoading = true
switch (this.backendPreference) {
case 'local':
this.getCommentDataLocal({
videoId: this.id,
setCookie: false,
sortByNewest: this.sortNewest,
continuation: this.nextPageToken ? this.nextPageToken : undefined
})
break
case 'invidious':
this.getCommentDataInvidious()
break
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
this.getCommentDataInvidious()
} else {
this.getCommentDataLocal()
}
},
@ -135,7 +97,11 @@ export default defineComponent({
if (this.commentData.length === 0 || this.nextPageToken === null || typeof this.nextPageToken === 'undefined') {
showToast(this.$t('Comments.There are no more comments for this video'))
} else {
this.getCommentData()
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
this.getCommentDataInvidious()
} else {
this.getCommentDataLocal(true)
}
}
},
@ -148,100 +114,87 @@ export default defineComponent({
},
getCommentReplies: function (index) {
switch (this.commentData[index].dataType) {
case 'local':
this.getCommentRepliesLocal({
videoId: this.id,
setCookie: false,
sortByNewest: this.sortNewest,
replyToken: this.commentData[index].replyToken,
index: index
})
break
case 'invidious':
this.getCommentRepliesInvidious(index)
break
if (process.env.IS_ELECTRON) {
switch (this.commentData[index].dataType) {
case 'local':
this.getCommentRepliesLocal(index)
break
case 'invidious':
this.getCommentRepliesInvidious(index)
break
}
} else {
this.getCommentRepliesInvidious(index)
}
},
getCommentDataLocal: function (payload) {
ytcm.getComments(payload).then((response) => {
this.parseLocalCommentData(response, null)
}).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 Invidious API'))
this.getCommentDataInvidious()
getCommentDataLocal: async function (more) {
try {
/** @type {import('youtubei.js/dist/src/parser/youtube/Comments').default} */
let comments
if (more) {
comments = await this.nextPageToken.getContinuation()
} else {
this.isLoading = false
comments = await getLocalComments(this.id, this.sortNewest)
}
})
},
getCommentRepliesLocal: function (payload) {
showToast(this.$t('Comments.Getting comment replies, please wait'))
const parsedComments = comments.contents
.map(commentThread => parseLocalComment(commentThread.comment, commentThread))
ytcm.getCommentReplies(payload).then((response) => {
this.parseLocalCommentData(response, payload.index)
}).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 Invidious API'))
this.getCommentDataInvidious()
if (more) {
this.commentData = this.commentData.concat(parsedComments)
} else {
this.isLoading = false
}
})
},
parseLocalCommentData: function (response, index = null) {
const commentData = response.comments.map((comment) => {
comment.authorLink = comment.authorId
comment.showReplies = false
comment.authorThumb = comment.authorThumb[0].url
comment.replies = []
comment.dataType = 'local'
comment.time = toLocalePublicationString({
publishText: (comment.time + ' ago')
})
if (this.hideCommentLikes) {
comment.likes = null
this.commentData = parsedComments
}
comment.text = autolinker.link(stripHTML(comment.text))
if (comment.customEmojis.length > 0) {
comment.customEmojis.forEach(emoji => {
comment.text = comment.text.replace(emoji.text, `<img width="14" height="14" class="commentCustomEmoji" alt="${emoji.text.substring(2, emoji.text.length - 1)}" src="${emoji.emojiThumbnails[0].url}">`)
})
}
return comment
})
if (index !== null) {
if (this.commentData[index].replies.length === 0 || this.commentData[index].replies[this.commentData[index].replies.length - 1].commentId !== commentData[commentData.length - 1].commentId) {
this.commentData[index].replies = this.commentData[index].replies.concat(commentData)
this.commentData[index].replyToken = response.continuation
this.commentData[index].showReplies = true
}
} else {
if (this.sortingChanged) {
this.commentData = []
this.sortingChanged = false
}
this.commentData = this.commentData.concat(commentData)
this.nextPageToken = comments.has_continuation ? comments : null
this.isLoading = false
this.showComments = true
this.nextPageToken = response.continuation
} 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 Invidious API'))
this.getCommentDataInvidious()
} else {
this.isLoading = false
}
}
},
getCommentRepliesLocal: async function (index) {
showToast(this.$t('Comments.Getting comment replies, please wait'))
try {
const comment = this.commentData[index]
/** @type {import('youtubei.js/dist/src/parser/classes/comments/CommentThread').default} */
const commentThread = comment.replyToken
if (comment.replies.length > 0) {
await commentThread.getContinuation()
comment.replies = comment.replies.concat(commentThread.replies.map(reply => parseLocalComment(reply)))
} else {
await commentThread.getReplies()
comment.replies = commentThread.replies.map(reply => parseLocalComment(reply))
}
comment.replyToken = commentThread.has_continuation ? commentThread : null
comment.showReplies = true
} 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 Invidious API'))
this.getCommentDataInvidious()
} else {
this.isLoading = false
}
}
},
@ -263,12 +216,7 @@ export default defineComponent({
})
if (process.env.IS_ELECTRON && this.backendFallback && this.backendPreference === 'invidious') {
showToast(this.$t('Falling back to local API'))
this.getCommentDataLocal({
videoId: this.id,
setCookie: false,
sortByNewest: this.sortNewest,
continuation: this.nextPageToken ? this.nextPageToken : undefined
})
this.getCommentDataLocal()
} else {
this.isLoading = false
}

View File

@ -104,11 +104,14 @@
@timestamp-event="onTimestamp"
/>
<p class="commentLikeCount">
<font-awesome-icon
<template
v-if="!hideCommentLikes"
:icon="['fas', 'thumbs-up']"
/>
{{ comment.likes }}
>
<font-awesome-icon
:icon="['fas', 'thumbs-up']"
/>
{{ comment.likes }}
</template>
<span
v-if="comment.isHearted"
class="commentHeartBadge"
@ -168,18 +171,15 @@
>
</router-link>
<p class="commentAuthorWrapper">
<span
<router-link
class="commentAuthor"
:class="{
commentOwner: reply.isOwner
}"
:to="`/channel/${reply.authorLink}`"
>
<router-link
:to="`/channel/${reply.authorLink}`"
>
{{ reply.author }}
</router-link>
</span>
{{ reply.author }}
</router-link>
<img
v-if="reply.isMember"
:src="reply.memberIconUrl"
@ -196,11 +196,15 @@
@timestamp-event="onTimestamp"
/>
<p class="commentLikeCount">
<font-awesome-icon
<template
v-if="!hideCommentLikes"
:icon="['fas', 'thumbs-up']"
/>
{{ reply.likes }}
>
<font-awesome-icon
v-if="!hideCommentLikes"
:icon="['fas', 'thumbs-up']"
/>
{{ reply.likes }}
</template>
</p>
<p
v-if="reply.numReplies > 0"

View File

@ -1,10 +1,15 @@
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 Autolinker from 'autolinker'
import { join } from 'path'
import { PlayerCache } from './PlayerCache'
import { extractNumberFromString, getUserDataPath } from '../utils'
import {
extractNumberFromString,
getUserDataPath,
toLocalePublicationString
} from '../utils'
/**
* Creates a lightweight Innertube instance, which is faster to create or
@ -141,6 +146,11 @@ export async function getLocalVideoInfo(id, attemptBypass = false) {
return info
}
export async function getLocalComments(id, sortByNewest = false) {
const innertube = await createInnertube()
return innertube.getComments(id, sortByNewest ? 'NEWEST_FIRST' : 'TOP_COMMENTS')
}
/**
* @param {import('youtubei.js/dist/src/parser/classes/misc/Format').default[]} formats
* @param {import('youtubei.js/dist/index').Player} player
@ -368,7 +378,7 @@ export function parseLocalTextRuns(runs, emojiSize = 16) {
// that way we avoid layout shifts when it loads
parsedRuns.push(`<img src="${emoji.image[0].url}" alt="${altText}" width="${emojiSize}" height="${emojiSize}" loading="lazy" style="vertical-align: middle">`)
} else {
const { text, endpoint } = run
const { text, bold, italics, strikethrough, endpoint } = run
if (endpoint && !text.startsWith('#')) {
switch (endpoint.metadata.page_type) {
@ -379,13 +389,15 @@ export function parseLocalTextRuns(runs, emojiSize = 16) {
parsedRuns.push(`https://www.youtube.com${endpoint.metadata.url}`)
}
break
case 'WEB_PAGE_TYPE_CHANNEL':
if (text.startsWith('@')) {
parsedRuns.push(`<a href="https://www.youtube.com/channel/${endpoint.payload.browseId}">${text}</a>`)
case 'WEB_PAGE_TYPE_CHANNEL': {
const trimmedText = text.trim()
if (trimmedText.startsWith('@')) {
parsedRuns.push(`<a href="https://www.youtube.com/channel/${endpoint.payload.browseId}">${trimmedText}</a>`)
} else {
parsedRuns.push(`https://www.youtube.com${endpoint.metadata.url}`)
}
break
}
case 'WEB_PAGE_TYPE_PLAYLIST':
parsedRuns.push(`https://www.youtube.com${endpoint.metadata.url}`)
break
@ -411,7 +423,20 @@ export function parseLocalTextRuns(runs, emojiSize = 16) {
}
}
} else {
parsedRuns.push(text)
let formattedText = text
if (bold) {
formattedText = `<b>${formattedText}</b>`
}
if (italics) {
formattedText = `<i>${formattedText}</i>`
}
if (strikethrough) {
formattedText = `<s>${formattedText}</s>`
}
parsedRuns.push(formattedText)
}
}
}
@ -437,3 +462,36 @@ export function mapLocalFormat(format) {
url: format.url
}
}
/**
* @param {import('youtubei.js/dist/src/parser/classes/comments/Comment').default} comment
* @param {import('youtubei.js/dist/src/parser/classes/comments/CommentThread').default} commentThread
*/
export function parseLocalComment(comment, commentThread = undefined) {
let hasOwnerReplied = false
let replyToken = null
if (commentThread?.has_replies) {
hasOwnerReplied = commentThread.comment_replies_data.has_channel_owner_replied
replyToken = commentThread
}
return {
dataType: 'local',
authorLink: comment.author.id,
author: comment.author.name,
authorThumb: comment.author.best_thumbnail.url,
isPinned: comment.is_pinned,
isOwner: comment.author_is_channel_owner,
isMember: comment.is_member,
text: Autolinker.link(parseLocalTextRuns(comment.content.runs, 16)),
time: toLocalePublicationString({ publishText: comment.published.text.replace('(edited)', '').trim() }),
likes: comment.vote_count,
isHearted: comment.is_hearted,
numReplies: comment.reply_count,
hasOwnerReplied,
replyToken,
showReplies: false,
replies: []
}
}

View File

@ -1058,13 +1058,6 @@
resolved "https://registry.yarnpkg.com/@fortawesome/vue-fontawesome/-/vue-fontawesome-2.0.9.tgz#7df35818135be54fd87568f16ec7b998a3d0cce8"
integrity sha512-tUmO92PFHbLOplitjHNBVGMJm6S57vp16tBXJVPKSI/6CfjrgLycqKxEpC6f7qsOqUdoXs5nIv4HLUfrOMHzuw==
"@freetube/yt-comment-scraper@^6.2.0":
version "6.2.0"
resolved "https://registry.yarnpkg.com/@freetube/yt-comment-scraper/-/yt-comment-scraper-6.2.0.tgz#ed11d65111d03076ff842eb9c3eb25413f8632ab"
integrity sha512-69mBsvQ50rUBTUDfR6s1OaiH2sEmmx9T0uV2Qpp0ckMhL0sxxMZtxujraHV2FHDtOm5jG7uygi3Gseb54C9DGw==
dependencies:
axios "^0.27.2"
"@humanwhocodes/config-array@^0.11.8":
version "0.11.8"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9"
@ -2080,14 +2073,6 @@ aws4@^1.8.0:
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
axios@^0.27.2:
version "0.27.2"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972"
integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==
dependencies:
follow-redirects "^1.14.9"
form-data "^4.0.0"
axios@^1.1.2:
version "1.1.3"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.1.3.tgz#8274250dada2edf53814ed7db644b9c2866c1e35"
@ -4395,11 +4380,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.14.9:
version "1.15.1"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5"
integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA==
follow-redirects@^1.15.0:
version "1.15.2"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"