Migrate live chat to YouTube.js (#3054)

This commit is contained in:
absidue 2023-01-13 17:54:22 +01:00 committed by GitHub
parent 7becd36120
commit ae50ec7205
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 242 additions and 189 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/youtube-chat": "^1.1.2",
"@freetube/yt-comment-scraper": "^6.2.0",
"@silvermine/videojs-quality-selector": "^1.2.5",
"autolinker": "^4.0.0",
@ -77,7 +76,7 @@
"vue-observe-visibility": "^1.0.0",
"vue-router": "^3.6.5",
"vuex": "^3.6.2",
"youtubei.js": "^2.8.0",
"youtubei.js": "^2.9.0",
"yt-channel-info": "^3.2.1"
},
"devDependencies": {

View File

@ -4,9 +4,8 @@ import FtCard from '../ft-card/ft-card.vue'
import FtButton from '../ft-button/ft-button.vue'
import autolinker from 'autolinker'
import { LiveChat } from '@freetube/youtube-chat'
import { getRandomColorClass } from '../../helpers/colors'
import { stripHTML } from '../../helpers/utils'
import { getLocalVideoInfo, parseLocalTextRuns } from '../../helpers/api/local'
export default Vue.extend({
name: 'WatchVideoLiveChat',
@ -15,23 +14,23 @@ export default Vue.extend({
'ft-card': FtCard,
'ft-button': FtButton
},
beforeRouteLeave: function () {
this.liveChat.stop()
this.hasEnded = true
},
props: {
liveChat: {
type: EventTarget,
default: null
},
videoId: {
type: String,
required: true
},
channelName: {
channelId: {
type: String,
required: true
}
},
data: function () {
return {
liveChat: null,
liveChatInstance: null,
isLoading: true,
hasError: false,
hasEnded: false,
@ -43,15 +42,15 @@ export default Vue.extend({
comments: [],
superChatComments: [],
superChat: {
id: '',
author: {
name: '',
thumbnail: ''
thumbnailUrl: ''
},
message: [
''
],
message: '',
superChat: {
amount: ''
amount: '',
colorClass: ''
}
}
}
@ -73,14 +72,15 @@ export default Vue.extend({
}
},
hideLiveChat: function () {
return this.$store.getters.getHideLiveChat
},
scrollingBehaviour: function () {
return this.$store.getters.getDisableSmoothScrolling ? 'auto' : 'smooth'
}
},
beforeDestroy: function () {
this.hasEnded = true
this.liveChatInstance?.stop()
this.liveChatInstance = null
},
created: function () {
if (!process.env.IS_ELECTRON) {
this.hasError = true
@ -88,7 +88,8 @@ export default Vue.extend({
} else {
switch (this.backendPreference) {
case 'local':
this.getLiveChatLocal()
this.liveChatInstance = this.liveChat
this.startLiveChatLocal()
break
case 'invidious':
if (this.backendFallback) {
@ -111,95 +112,137 @@ export default Vue.extend({
this.getLiveChatLocal()
},
getLiveChatLocal: function () {
this.liveChat = new LiveChat({ liveId: this.videoId })
getLiveChatLocal: async function () {
const videoInfo = await getLocalVideoInfo(this.videoId)
this.liveChatInstance = videoInfo.getLiveChat()
this.isLoading = false
this.liveChat.on('start', (liveId) => {
this.isLoading = false
})
this.liveChat.on('end', (reason) => {
console.error('Live chat has ended')
console.error(reason)
this.hasError = true
this.showEnableChat = false
this.errorMessage = this.$t('Video["Chat is disabled or the Live Stream has ended."]')
})
this.liveChat.on('error', (err) => {
this.hasError = true
this.errorMessage = err
this.showEnableChat = false
})
this.liveChat.on('comment', (comment) => {
this.parseLiveChatComment(comment)
})
this.liveChat.start()
this.startLiveChatLocal()
},
parseLiveChatComment: function (comment) {
if (this.hasEnded) {
return
}
startLiveChatLocal: function () {
this.liveChatInstance.once('start', initialData => {
/**
* @type {import ('youtubei.js/dist/src/parser/index').LiveChatContinuation}
*/
const liveChatContinuation = initialData
comment.messageHtml = ''
const actions = liveChatContinuation.actions.filter(action => action.type === 'AddChatItemAction')
comment.message.forEach((text) => {
if (typeof text === 'undefined') return
if (typeof (text.navigationEndpoint) !== 'undefined') {
if (typeof (text.navigationEndpoint.watchEndpoint) !== 'undefined') {
const htmlRef = `<a href="https://www.youtube.com/watch?v=${text.navigationEndpoint.watchEndpoint.videoId}">${text.text}</a>`
comment.messageHtml = stripHTML(comment.messageHtml) + htmlRef
} else {
comment.messageHtml = stripHTML(comment.messageHtml + text.text)
for (const { item } of actions) {
switch (item.type) {
case 'LiveChatTextMessage':
this.parseLiveChatComment(item)
break
case 'LiveChatPaidMessage':
this.parseLiveChatSuperChat(item)
}
}
this.isLoading = false
setTimeout(() => {
this.$refs.liveChatComments?.scrollTo({
top: this.$refs.liveChatComments.scrollHeight,
behavior: 'instant'
})
})
})
this.liveChatInstance.on('chat-update', action => {
if (this.hasEnded) {
return
}
if (action.type === 'AddChatItemAction') {
switch (action.item.type) {
case 'LiveChatTextMessage':
this.parseLiveChatComment(action.item)
break
case 'LiveChatPaidMessage':
this.parseLiveChatSuperChat(action.item)
break
}
} else if (typeof (text.alt) !== 'undefined') {
const htmlImg = `<img src="${text.url}" alt="${text.alt}" class="liveChatEmoji" height="24" width="24" />`
comment.messageHtml = stripHTML(comment.messageHtml) + htmlImg
} else {
comment.messageHtml = stripHTML(comment.messageHtml + text.text)
}
})
comment.messageHtml = autolinker.link(comment.messageHtml)
this.liveChatInstance.once('end', () => {
this.hasEnded = true
this.liveChatInstance = null
})
if (typeof this.$refs.liveChatComments === 'undefined' && typeof this.$refs.liveChatMessage === 'undefined') {
console.error("Can't find chat object. Stopping chat connection")
this.liveChat.stop()
return
this.liveChatInstance.once('error', error => {
this.liveChatInstance.stop()
this.liveChatInstance = null
console.error(error)
this.errorMessage = error
this.hasError = true
this.isLoading = false
this.hasEnded = true
})
this.liveChatInstance.start()
},
/**
* @param {import('youtubei.js/dist/src/parser/classes/livechat/items/LiveChatTextMessage').default} comment
*/
parseLiveChatComment: function (comment) {
/**
* can also be undefined if there is no badge
* @type {import('youtubei.js/dist/src/parser/classes/LiveChatAuthorBadge').default}
*/
const badge = comment.author.badges.find(badge => badge.type === 'LiveChatAuthorBadge' && badge.custom_thumbnail)
const parsedComment = {
message: autolinker.link(parseLocalTextRuns(comment.message.runs, 20)),
author: {
name: comment.author.name.text,
thumbnailUrl: comment.author.thumbnails.at(-1).url,
isOwner: comment.author.id === this.channelId,
isModerator: comment.author.is_moderator,
isMember: !!badge
}
}
this.comments.push(comment)
if (typeof (comment.superchat) !== 'undefined') {
comment.superchat.colorClass = getRandomColorClass()
this.superChatComments.unshift(comment)
setTimeout(() => {
this.removeFromSuperChat(comment.id)
}, 120000)
if (badge) {
parsedComment.badge = {
url: badge.custom_thumbnail.at(-1).url,
tooltip: badge.tooltip ?? ''
}
}
if (comment.author.name[0] === 'Ge' || comment.author.name[0] === 'Ne') {
comment.superChat = {
amount: '$5.00',
this.pushComment(parsedComment)
},
/**
* @param {import('youtubei.js/dist/src/parser/classes/livechat/items/LiveChatPaidMessage').default} superChat
*/
parseLiveChatSuperChat: function (superChat) {
const parsedComment = {
id: superChat.id,
message: autolinker.link(parseLocalTextRuns(superChat.message.runs, 20)),
author: {
name: superChat.author.name.text,
thumbnailUrl: superChat.author.thumbnails[0].url
},
superChat: {
amount: superChat.purchase_amount,
colorClass: getRandomColorClass()
}
this.superChatComments.unshift(comment)
setTimeout(() => {
this.removeFromSuperChat(comment.id)
}, 120000)
}
if (this.stayAtBottom) {
this.superChatComments.unshift(parsedComment)
setTimeout(() => {
this.removeFromSuperChat(parsedComment.id)
}, 120000)
this.pushComment(parsedComment)
},
pushComment: function (comment) {
this.comments.push(comment)
if (!this.isLoading && this.stayAtBottom) {
setTimeout(() => {
this.$refs.liveChatComments?.scrollTo({
top: this.$refs.liveChatComments.scrollHeight,

View File

@ -1,6 +1,5 @@
<template>
<ft-card
v-if="!hideLiveChat"
class="relative"
>
<ft-loader
@ -52,7 +51,7 @@
:aria-label="$t('Video.Show Super Chat Comment')"
:style="{ backgroundColor: 'var(--primary-color)' }"
class="superChat"
:class="comment.superchat.colorClass"
:class="comment.superChat.colorClass"
role="button"
tabindex="0"
@click="showSuperChatComment(comment)"
@ -60,7 +59,7 @@
@keydown.enter.prevent="showSuperChatComment(comment)"
>
<img
:src="comment.author.thumbnail.url"
:src="comment.author.thumbnailUrl"
class="channelThumbnail"
alt=""
>
@ -70,7 +69,7 @@
<span
class="donationAmount"
>
{{ comment.superchat.amount }}
{{ comment.superChat.amount }}
</span>
</p>
</div>
@ -78,7 +77,7 @@
<div
v-if="showSuperChat"
class="openedSuperChat"
:class="superChat.superchat.colorClass"
:class="superChat.superChat.colorClass"
role="button"
tabindex="0"
@click="showSuperChat = false"
@ -93,7 +92,7 @@
class="upperSuperChatMessage"
>
<img
:src="superChat.author.thumbnail.url"
:src="superChat.author.thumbnailUrl"
class="channelThumbnail"
alt=""
>
@ -105,13 +104,12 @@
<p
class="donationAmount"
>
{{ superChat.superchat.amount }}
{{ superChat.superChat.amount }}
</p>
</div>
<p
v-if="superChat.message.length > 0"
class="chatMessage"
v-html="superChat.messageHtml"
v-html="superChat.message"
/>
</div>
</div>
@ -125,17 +123,16 @@
v-for="(comment, index) in comments"
:key="index"
class="comment"
:class="{ superChatMessage: typeof (comment.superchat) !== 'undefined' }"
:class="comment.superChat ? `superChatMessage ${comment.superChat.colorClass}` : ''"
>
<div
v-if="typeof (comment.superchat) !== 'undefined'"
:class="comment.superchat.colorClass"
<template
v-if="comment.superChat"
>
<div
class="upperSuperChatMessage"
>
<img
:src="comment.author.thumbnail.url"
:src="comment.author.thumbnailUrl"
class="channelThumbnail"
alt=""
>
@ -147,20 +144,20 @@
<p
class="donationAmount"
>
{{ comment.superchat.amount }}
{{ comment.superChat.amount }}
</p>
</div>
<p
v-if="comment.message.length > 0"
v-if="comment.message"
class="chatMessage"
v-html="comment.messageHtml"
v-html="comment.message"
/>
</div>
</template>
<template
v-else
>
<img
:src="comment.author.thumbnail.url"
:src="comment.author.thumbnailUrl"
class="channelThumbnail"
alt=""
>
@ -170,28 +167,27 @@
<span
class="channelName"
:class="{
member: typeof (comment.author.badge) !== 'undefined' || comment.membership,
moderator: comment.isOwner,
owner: comment.author.name === channelName
member: comment.author.isMember,
moderator: comment.author.isModerator,
owner: comment.author.isOwner
}"
>
{{ comment.author.name }}
</span>
<span
v-if="typeof (comment.author.badge) !== 'undefined'"
v-if="comment.author.badge"
class="badge"
>
<img
:src="comment.author.badge.thumbnail.url"
:src="comment.author.badge.url"
alt=""
:title="comment.author.badge.thumbnail.alt"
:title="comment.author.badge.tooltip"
class="badgeImage"
>
</span>
<span
v-if="comment.message.length > 0"
class="chatMessage"
v-html="comment.messageHtml"
v-html="comment.message"
/>
</p>
</template>

View File

@ -1,5 +1,6 @@
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 { join } from 'path'
import { PlayerCache } from './PlayerCache'
@ -305,63 +306,89 @@ function convertSearchFilters(filters) {
*/
/**
* @param {TextRun[]} textRuns
* @param {(TextRun|EmojiRun)[]} runs
* @param {number} emojiSize
*/
export function parseLocalTextRuns(textRuns) {
if (!Array.isArray(textRuns)) {
export function parseLocalTextRuns(runs, emojiSize = 16) {
if (!Array.isArray(runs)) {
throw new Error('not an array of text runs')
}
const timestampRegex = /^(?:\d+:){1,2}\d+$/
const runs = []
const parsedRuns = []
for (const { text, endpoint } of textRuns) {
if (endpoint && !text.startsWith('#')) {
switch (endpoint.metadata.page_type) {
case 'WEB_PAGE_TYPE_WATCH':
if (timestampRegex.test(text)) {
runs.push(text)
} else {
runs.push(`https://www.youtube.com${endpoint.metadata.url}`)
}
break
case 'WEB_PAGE_TYPE_CHANNEL':
if (text.startsWith('@')) {
runs.push(`<a href="https://www.youtube.com/channel/${endpoint.payload.browseId}">${text}</a>`)
} else {
runs.push(`https://www.youtube.com${endpoint.metadata.url}`)
}
break
case 'WEB_PAGE_TYPE_PLAYLIST':
runs.push(`https://www.youtube.com${endpoint.metadata.url}`)
break
case 'WEB_PAGE_TYPE_UNKNOWN':
default: {
const url = new URL(endpoint.payload.url)
if (url.hostname === 'www.youtube.com' && url.pathname === '/redirect' && url.searchParams.has('q')) {
// remove utm tracking parameters
const realURL = new URL(url.searchParams.get('q'))
for (const run of runs) {
if (run instanceof EmojiRun) {
const { emoji, text } = run
realURL.searchParams.delete('utm_source')
realURL.searchParams.delete('utm_medium')
realURL.searchParams.delete('utm_campaign')
realURL.searchParams.delete('utm_term')
realURL.searchParams.delete('utm_content')
let altText
runs.push(realURL.toString())
} else {
// this is probably a special YouTube URL like http://www.youtube.com/approachingnirvana
runs.push(endpoint.payload.url)
}
break
if (emoji.is_custom) {
if (emoji.shortcuts.length > 0) {
altText = emoji.shortcuts[0]
} else if (emoji.search_terms.length > 0) {
altText = emoji.search_terms.join(', ')
} else {
altText = 'Custom emoji'
}
} else {
altText = text
}
// lazy load the emoji image so it doesn't delay rendering of the text
// by defining a height and width, that space is reserved until the image is loaded
// 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 {
runs.push(text)
const { text, endpoint } = run
if (endpoint && !text.startsWith('#')) {
switch (endpoint.metadata.page_type) {
case 'WEB_PAGE_TYPE_WATCH':
if (timestampRegex.test(text)) {
parsedRuns.push(text)
} else {
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>`)
} 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
case 'WEB_PAGE_TYPE_UNKNOWN':
default: {
const url = new URL(endpoint.payload.url)
if (url.hostname === 'www.youtube.com' && url.pathname === '/redirect' && url.searchParams.has('q')) {
// remove utm tracking parameters
const realURL = new URL(url.searchParams.get('q'))
realURL.searchParams.delete('utm_source')
realURL.searchParams.delete('utm_medium')
realURL.searchParams.delete('utm_campaign')
realURL.searchParams.delete('utm_term')
realURL.searchParams.delete('utm_content')
parsedRuns.push(realURL.toString())
} else {
// this is probably a special YouTube URL like http://www.youtube.com/approachingnirvana
parsedRuns.push(endpoint.payload.url)
}
break
}
}
} else {
parsedRuns.push(text)
}
}
}
return runs.join('')
return parsedRuns.join('')
}
/**

View File

@ -59,6 +59,7 @@ export default Vue.extend({
hidePlayer: false,
isFamilyFriendly: false,
isLive: false,
liveChat: null,
isLiveContent: false,
isUpcoming: false,
upcomingTimestamp: null,
@ -304,7 +305,7 @@ export default Vue.extend({
channelId: this.channelId
})
this.videoPublished = new Date(result.primary_info.published.text.replace('-', '/')).getTime()
this.videoPublished = new Date(result.page[0].microformat.publish_date.replace('-', '/')).getTime()
if (result.secondary_info?.description.runs) {
try {
@ -410,6 +411,12 @@ export default Vue.extend({
this.videoChapters = chapters
if (!this.hideLiveChat && this.isLive && this.isLiveContent && result.livechat) {
this.liveChat = result.getLiveChat()
} else {
this.liveChat = null
}
// the bypassed result is missing some of the info that we extract in the code above
// so we only overwrite the result here
// we need the bypassed result for the streaming data and the subtitles

View File

@ -153,9 +153,10 @@
class="sidebarArea"
>
<watch-video-live-chat
v-if="!isLoading && isLive"
v-if="!isLoading && !hideLiveChat && isLive"
:live-chat="liveChat"
:video-id="videoId"
:channel-name="channelName"
:channel-id="channelId"
class="watchVideoSideBar watchVideoPlaylist"
:class="{ theatrePlaylist: useTheatreMode }"
/>

View File

@ -1058,14 +1058,6 @@
resolved "https://registry.yarnpkg.com/@fortawesome/vue-fontawesome/-/vue-fontawesome-2.0.9.tgz#7df35818135be54fd87568f16ec7b998a3d0cce8"
integrity sha512-tUmO92PFHbLOplitjHNBVGMJm6S57vp16tBXJVPKSI/6CfjrgLycqKxEpC6f7qsOqUdoXs5nIv4HLUfrOMHzuw==
"@freetube/youtube-chat@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@freetube/youtube-chat/-/youtube-chat-1.1.2.tgz#29e8e1fc94a278e2da38c1241fb611a744a3a0a6"
integrity sha512-VT/00nshjwf5WC7cZ9EV5lGMo83DDyC0XYnoNwmSk34sWCco8h8/EXKnCDTG82clZrS2mJHYlYRfXN/6FWd6ig==
dependencies:
axios "^0.21.1"
tslib "^1.11.1"
"@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"
@ -2078,13 +2070,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.21.1:
version "0.21.4"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575"
integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==
dependencies:
follow-redirects "^1.14.0"
axios@^0.27.2:
version "0.27.2"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972"
@ -4361,7 +4346,7 @@ flatted@^3.1.0:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.5.tgz#76c8584f4fc843db64702a6bd04ab7a8bd666da3"
integrity sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==
follow-redirects@^1.0.0, follow-redirects@^1.14.0:
follow-redirects@^1.0.0:
version "1.15.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.0.tgz#06441868281c86d0dda4ad8bdaead2d02dca89d4"
integrity sha512-aExlJShTV4qOUOL7yF1U5tvLCB0xQuudbf6toyYA0E/acBNw71mvjFTnLaRp50aQaYocMR0a/RMMBIHeZnGyjQ==
@ -8414,11 +8399,6 @@ tsconfig-paths@^3.14.1:
minimist "^1.2.6"
strip-bom "^3.0.0"
tslib@^1.11.1:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.3, tslib@^2.3.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
@ -9174,10 +9154,10 @@ 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.8.0:
version "2.8.0"
resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-2.8.0.tgz#f0170f390791cde733facf615f3cf0e2470c31de"
integrity sha512-UEH3wP1Wmrmnh2wJohhg+rAq94bF2EqGnlnph2z91kZ+I1tQzlZD09Zc7QNqCYV0t2G4Cl2Lu+DqXrdhf1/SoQ==
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==
dependencies:
"@protobuf-ts/runtime" "^2.7.0"
jintr "^0.3.1"