diff --git a/package.json b/package.json index 51bec4a6d..d39e5a3fa 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/renderer/helpers/api/local.js b/src/renderer/helpers/api/local.js index c8ea0011c..49f0acc1d 100644 --- a/src/renderer/helpers/api/local.js +++ b/src/renderer/helpers/api/local.js @@ -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 diff --git a/src/renderer/views/Channel/Channel.js b/src/renderer/views/Channel/Channel.js index 3bd389a65..41ef0a434 100644 --- a/src/renderer/views/Channel/Channel.js +++ b/src/renderer/views/Channel/Channel.js @@ -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': diff --git a/src/renderer/views/Channel/Channel.vue b/src/renderer/views/Channel/Channel.vue index 5f3c31b63..f9b271058 100644 --- a/src/renderer/views/Channel/Channel.vue +++ b/src/renderer/views/Channel/Channel.vue @@ -98,6 +98,19 @@ > {{ $t("Channel.Videos.Videos").toUpperCase() }} +
+ + + +

+ {{ $t("Channel.Shorts.This channel does not currently have any shorts") }} +

+