mirror of https://github.com/FreeTubeApp/FreeTube
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:
parent
a4d45b5fa8
commit
9019be2419
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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'"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue