Implement the shorts tab on channel pages (#3533)

* Implement the shorts tab on channel pages

* Remove testing code

* Upgrade YouTube.js
This commit is contained in:
absidue 2023-05-15 04:01:18 +02:00 committed by GitHub
parent a4d45b5fa8
commit 9019be2419
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 277 additions and 5 deletions

View File

@ -76,7 +76,7 @@
"vue-router": "^3.6.5",
"vue-tiny-slider": "^0.1.39",
"vuex": "^3.6.2",
"youtubei.js": "^5.0.3"
"youtubei.js": "^5.1.0"
},
"devDependencies": {
"@babel/core": "^7.21.8",

View File

@ -263,6 +263,83 @@ export function parseLocalChannelVideos(videos, author) {
return parsedVideos
}
/**
* @param {import('youtubei.js').YTNodes.ReelItem[]} shorts
* @param {Misc.Author} author
*/
export function parseLocalChannelShorts(shorts, author) {
return shorts.map(short => {
// unfortunately the only place with the duration is the accesibility string
const duration = parseShortDuration(short.accessibility_label, short.id)
return {
type: 'video',
videoId: short.id,
title: short.title.text,
author: author.name,
authorId: author.id,
viewCount: parseLocalSubscriberCount(short.views.text),
lengthSeconds: isNaN(duration) ? '' : duration
}
})
}
/**
* Shorts can only be up to 60 seconds long, so we only need to handle seconds and minutes
* Of course this is YouTube, so are edge cases that don't match the docs, like example 3 taken from LTT
*
* https://support.google.com/youtube/answer/10059070?hl=en
*
* Example input strings:
* - These mice keep getting WEIRDER... - 59 seconds - play video
* - How Low Can Our Resolution Go? - 1 minute - play video
* - I just found out about Elon. #SHORTS - 1 minute, 1 second - play video
* @param {string} accessibilityLabel
* @param {string} videoId only used for error logging
*/
function parseShortDuration(accessibilityLabel, videoId) {
// we want to count from the end of the array,
// as it's possible that the title could contain a `-` too
const timeString = accessibilityLabel.split('-').at(-2)
if (typeof timeString === 'undefined') {
console.error(`Failed to parse local API short duration from accessibility label. video ID: ${videoId}, text: "${accessibilityLabel}"`)
return NaN
}
let duration = 0
const matches = timeString.matchAll(/(\d+) (second|minute)s?/g)
// matchAll returns an iterator, which doesn't have a length property
// so we need to check if it's empty this way instead
let validDuration = false
for (const match of matches) {
let number = parseInt(match[1])
if (isNaN(number) || match[2].length === 0) {
validDuration = false
break
}
validDuration = true
if (match[2] === 'minute') {
number *= 60
}
duration += number
}
if (!validDuration) {
console.error(`Failed to parse local API short duration from accessibility label. video ID: ${videoId}, text: "${accessibilityLabel}"`)
return NaN
}
return duration
}
/**
* @typedef {import('youtubei.js').YTNodes.Playlist} Playlist
* @typedef {import('youtubei.js').YTNodes.GridPlaylist} GridPlaylist

View File

@ -24,6 +24,7 @@ import {
import {
getLocalChannel,
getLocalChannelId,
parseLocalChannelShorts,
parseLocalChannelVideos,
parseLocalCommunityPost,
parseLocalListPlaylist,
@ -51,6 +52,7 @@ export default defineComponent({
isElementListLoading: false,
currentTab: 'videos',
id: '',
/** @type {import('youtubei.js').YT.Channel|null} */
channelInstance: null,
channelName: '',
bannerUrl: '',
@ -58,6 +60,7 @@ export default defineComponent({
subCount: 0,
searchPage: 2,
videoContinuationData: null,
shortContinuationData: null,
liveContinuationData: null,
playlistContinuationData: null,
searchContinuationData: null,
@ -68,14 +71,17 @@ export default defineComponent({
joined: 0,
location: null,
videoSortBy: 'newest',
shortSortBy: 'newest',
liveSortBy: 'newest',
playlistSortBy: 'newest',
showVideoSortBy: true,
showShortSortBy: true,
showLiveSortBy: true,
showPlaylistSortBy: true,
lastSearchQuery: '',
relatedChannels: [],
latestVideos: [],
latestShorts: [],
latestLive: [],
latestPlaylists: [],
latestCommunityPosts: [],
@ -156,6 +162,8 @@ export default defineComponent({
switch (this.currentTab) {
case 'videos':
return !isNullOrEmpty(this.videoContinuationData)
case 'shorts':
return !isNullOrEmpty(this.shortContinuationData)
case 'live':
return !isNullOrEmpty(this.liveContinuationData)
case 'playlists':
@ -191,6 +199,7 @@ export default defineComponent({
tabInfoValues: function () {
const values = [
'videos',
'shorts',
'live',
'playlists',
'community',
@ -231,8 +240,12 @@ export default defineComponent({
this.searchPage = 2
this.relatedChannels = []
this.latestVideos = []
this.latestShorts = []
this.latestLive = []
this.videoSortBy = 'newest'
this.shortSortBy = 'newest'
this.liveSortBy = 'newest'
this.playlistSortBy = 'newest'
this.latestPlaylists = []
this.latestCommunityPosts = []
this.searchResults = []
@ -240,12 +253,14 @@ export default defineComponent({
this.apiUsed = ''
this.channelInstance = ''
this.videoContinuationData = null
this.shortContinuationData = null
this.liveContinuationData = null
this.playlistContinuationData = null
this.searchContinuationData = null
this.communityContinuationData = null
this.showSearchBar = true
this.showVideoSortBy = true
this.showShortSortBy = true
this.showLiveSortBy = true
this.showPlaylistSortBy = true
@ -294,6 +309,21 @@ export default defineComponent({
}
},
shortSortBy() {
this.isElementListLoading = true
this.latestShorts = []
switch (this.apiUsed) {
case 'local':
this.getChannelShortsLocal()
break
case 'invidious':
this.channelInvidiousShorts(true)
break
default:
this.getChannelShortsLocal()
}
},
liveSortBy () {
this.isElementListLoading = true
this.latestLive = []
@ -561,6 +591,10 @@ export default defineComponent({
this.getChannelVideosLocal()
}
if (channel.has_shorts) {
this.getChannelShortsLocal()
}
if (!this.hideLiveStreams && channel.has_live_streams) {
this.getChannelLiveLocal()
}
@ -680,6 +714,64 @@ export default defineComponent({
}
},
getChannelShortsLocal: async function () {
this.isElementListLoading = true
const expectedId = this.id
try {
/**
* @type {import('youtubei.js').YT.Channel}
*/
const channel = this.channelInstance
let shortsTab = await channel.getShorts()
this.showShortSortBy = shortsTab.filters.length > 1
if (this.showShortSortBy && this.shortSortBy !== 'newest') {
const index = this.videoShortLiveSelectValues.indexOf(this.shortSortBy)
shortsTab = await shortsTab.applyFilter(shortsTab.filters[index])
}
if (expectedId !== this.id) {
return
}
this.latestShorts = parseLocalChannelShorts(shortsTab.videos, channel.header.author)
this.shortContinuationData = shortsTab.has_continuation ? shortsTab : 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 (this.backendPreference === 'local' && this.backendFallback) {
showToast(this.$t('Falling back to Invidious API'))
this.getChannelInfoInvidious()
} else {
this.isLoading = false
}
}
},
getChannelShortsLocalMore: async function () {
try {
/**
* @type {import('youtubei.js').YT.ChannelListContinuation|import('youtubei.js').YT.FilteredChannelList}
*/
const continuation = await this.shortContinuationData.getContinuation()
this.latestShorts.push(...parseLocalChannelShorts(continuation.videos, this.channelInstance.header.author))
this.shortContinuationData = 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)
})
}
},
getChannelLiveLocal: async function () {
this.isElementListLoading = true
const expectedId = this.id
@ -791,6 +883,10 @@ export default defineComponent({
this.channelInvidiousVideos()
}
if (response.tabs.includes('shorts')) {
this.channelInvidiousShorts()
}
if (!this.hideLiveStreams && response.tabs.includes('streams')) {
this.channelInvidiousLive()
}
@ -860,6 +956,55 @@ export default defineComponent({
})
},
channelInvidiousShorts: function (sortByChanged) {
const payload = {
resource: 'channels',
id: this.id,
subResource: 'shorts',
params: {
sort_by: this.shortSortBy,
}
}
if (sortByChanged) {
this.shortContinuationData = null
}
let more = false
if (this.shortContinuationData) {
payload.params.continuation = this.shortContinuationData
more = true
}
if (!more) {
this.isElementListLoading = true
}
invidiousAPICall(payload).then((response) => {
// workaround for Invidious sending incorrect information
// https://github.com/iv-org/invidious/issues/3801
response.videos.forEach(video => {
video.isUpcoming = false
delete video.publishedText
delete video.premiereTimestamp
})
if (more) {
this.latestShorts.push(...response.videos)
} else {
this.latestShorts = response.videos
}
this.shortContinuationData = response.continuation || null
this.isElementListLoading = false
}).catch((err) => {
console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
})
},
channelInvidiousLive: function (sortByChanged) {
const payload = {
resource: 'channels',
@ -1165,6 +1310,16 @@ export default defineComponent({
break
}
break
case 'shorts':
switch (this.apiUsed) {
case 'local':
this.getChannelShortsLocalMore()
break
case 'invidious':
this.channelInvidiousShorts()
break
}
break
case 'live':
switch (this.apiUsed) {
case 'local':

View File

@ -98,6 +98,19 @@
>
{{ $t("Channel.Videos.Videos").toUpperCase() }}
</div>
<div
id="shortsTab"
class="tab"
:class="(currentTab==='shorts')?'selectedTab':''"
role="tab"
aria-selected="true"
aria-controls="shortPanel"
tabindex="0"
@click="changeTab('shorts')"
@keydown.left.right.enter.space="changeTab('shorts', $event)"
>
{{ $t("Channel.Shorts.Shorts").toUpperCase() }}
</div>
<div
v-if="!hideLiveStreams"
id="liveTab"
@ -189,6 +202,16 @@
:placeholder="$t('Search Filters.Sort By.Sort By')"
@change="videoSortBy = $event"
/>
<ft-select
v-if="showShortSortBy"
v-show="currentTab === 'shorts' && latestShorts.length > 0"
class="sortSelect"
:value="videoShortLiveSelectValues[0]"
:select-names="videoShortLiveSelectNames"
:select-values="videoShortLiveSelectValues"
:placeholder="$t('Search Filters.Sort By.Sort By')"
@change="shortSortBy = $event"
/>
<ft-select
v-if="!hideLiveStreams && showLiveSortBy"
v-show="currentTab === 'live' && latestLive.length > 0"
@ -230,6 +253,20 @@
{{ $t("Channel.Videos.This channel does not currently have any videos") }}
</p>
</ft-flex-box>
<ft-element-list
v-if="currentTab === 'shorts'"
id="shortPanel"
:data="latestShorts"
role="tabpanel"
aria-labelledby="shortsTab"
/>
<ft-flex-box
v-if="currentTab === 'shorts' && latestShorts.length === 0"
>
<p class="message">
{{ $t("Channel.Shorts.This channel does not currently have any shorts") }}
</p>
</ft-flex-box>
<ft-element-list
v-if="!hideLiveStreams"
v-show="currentTab === 'live'"

View File

@ -534,6 +534,9 @@ Channel:
Newest: Newest
Oldest: Oldest
Most Popular: Most Popular
Shorts:
Shorts: Shorts
This channel does not currently have any shorts: This channel does not currently have any shorts
Live:
Live: Live
This channel does not currently have any live streams: This channel does not currently

View File

@ -8473,10 +8473,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@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-5.0.3.tgz#3819ffd3e80592224808b586e189ee0e3ef16bb3"
integrity sha512-SHTUuFYZ0mVAHY01Vbj57sQPwLpORrh32FkFkVuSwZfcQvA9G/OMbhXSYC3QueZlXSSeT1a8yRudM4rKl4/Lhw==
youtubei.js@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-5.1.0.tgz#a71474f23fbf8679c1f517f3f8a33064e898683e"
integrity sha512-Rbe71rqqSVGj2kBv7FeIDbod+AEcd84F/7inSLWjt6jv8tmTGLjXr8cjlqzY5WhpNKMJuSUVKLFgCb3V0SHTUA==
dependencies:
jintr "^1.0.0"
linkedom "^0.14.12"