diff --git a/src/renderer/components/subscriptions-live/subscriptions-live.js b/src/renderer/components/subscriptions-live/subscriptions-live.js index 625da4a0096a6..ab3e8d0e74747 100644 --- a/src/renderer/components/subscriptions-live/subscriptions-live.js +++ b/src/renderer/components/subscriptions-live/subscriptions-live.js @@ -2,7 +2,13 @@ import { defineComponent } from 'vue' import { mapActions, mapMutations } from 'vuex' import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue' -import { setPublishedTimestampsInvidious, copyToClipboard, getRelativeTimeFromDate, showToast } from '../../helpers/utils' +import { + getChannelPlaylistId, + setPublishedTimestampsInvidious, + copyToClipboard, + getRelativeTimeFromDate, + showToast +} from '../../helpers/utils' import { invidiousAPICall, invidiousFetch } from '../../helpers/api/invidious' import { getLocalChannelLiveStreams } from '../../helpers/api/local' import { parseYouTubeRSSFeed, updateVideoListAfterProcessing } from '../../helpers/subscriptions' @@ -244,7 +250,7 @@ export default defineComponent({ }, getChannelLiveLocalRSS: async function (channel, failedAttempts = 0) { - const playlistId = channel.id.replace('UC', 'UULV') + const playlistId = getChannelPlaylistId(channel.id, 'live', 'newest') const feedUrl = `https://www.youtube.com/feeds/videos.xml?playlist_id=${playlistId}` try { @@ -353,7 +359,7 @@ export default defineComponent({ }, getChannelLiveInvidiousRSS: async function (channel, failedAttempts = 0) { - const playlistId = channel.id.replace('UC', 'UULV') + const playlistId = getChannelPlaylistId(channel.id, 'live', 'newest') const feedUrl = `${this.currentInvidiousInstanceUrl}/feed/playlist/${playlistId}` try { diff --git a/src/renderer/components/subscriptions-shorts/subscriptions-shorts.js b/src/renderer/components/subscriptions-shorts/subscriptions-shorts.js index 317e54b2a5416..101687cccd79f 100644 --- a/src/renderer/components/subscriptions-shorts/subscriptions-shorts.js +++ b/src/renderer/components/subscriptions-shorts/subscriptions-shorts.js @@ -3,7 +3,12 @@ import { mapActions, mapMutations } from 'vuex' import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue' import { parseYouTubeRSSFeed, updateVideoListAfterProcessing } from '../../helpers/subscriptions' -import { copyToClipboard, getRelativeTimeFromDate, showToast } from '../../helpers/utils' +import { + copyToClipboard, + getChannelPlaylistId, + getRelativeTimeFromDate, + showToast +} from '../../helpers/utils' import { invidiousFetch } from '../../helpers/api/invidious' export default defineComponent({ @@ -182,7 +187,7 @@ export default defineComponent({ }, getChannelShortsLocal: async function (channel, failedAttempts = 0) { - const playlistId = channel.id.replace('UC', 'UUSH') + const playlistId = getChannelPlaylistId(channel.id, 'shorts', 'newest') const feedUrl = `https://www.youtube.com/feeds/videos.xml?playlist_id=${playlistId}` try { @@ -231,7 +236,7 @@ export default defineComponent({ }, getChannelShortsInvidious: async function (channel, failedAttempts = 0) { - const playlistId = channel.id.replace('UC', 'UUSH') + const playlistId = getChannelPlaylistId(channel.id, 'shorts', 'newest') const feedUrl = `${this.currentInvidiousInstanceUrl}/feed/playlist/${playlistId}` try { diff --git a/src/renderer/components/subscriptions-videos/subscriptions-videos.js b/src/renderer/components/subscriptions-videos/subscriptions-videos.js index 395c591756565..d11120615fc23 100644 --- a/src/renderer/components/subscriptions-videos/subscriptions-videos.js +++ b/src/renderer/components/subscriptions-videos/subscriptions-videos.js @@ -2,7 +2,13 @@ import { defineComponent } from 'vue' import { mapActions, mapMutations } from 'vuex' import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue' -import { setPublishedTimestampsInvidious, copyToClipboard, getRelativeTimeFromDate, showToast } from '../../helpers/utils' +import { + setPublishedTimestampsInvidious, + copyToClipboard, + getRelativeTimeFromDate, + showToast, + getChannelPlaylistId +} from '../../helpers/utils' import { invidiousAPICall, invidiousFetch } from '../../helpers/api/invidious' import { getLocalChannelVideos } from '../../helpers/api/local' import { parseYouTubeRSSFeed, updateVideoListAfterProcessing } from '../../helpers/subscriptions' @@ -248,7 +254,7 @@ export default defineComponent({ }, getChannelVideosLocalRSS: async function (channel, failedAttempts = 0) { - const playlistId = channel.id.replace('UC', 'UULF') + const playlistId = getChannelPlaylistId(channel.id, 'videos', 'newest') const feedUrl = `https://www.youtube.com/feeds/videos.xml?playlist_id=${playlistId}` try { @@ -354,7 +360,7 @@ export default defineComponent({ }, getChannelVideosInvidiousRSS: async function (channel, failedAttempts = 0) { - const playlistId = channel.id.replace('UC', 'UULF') + const playlistId = getChannelPlaylistId(channel.id, 'videos', 'newest') const feedUrl = `${this.currentInvidiousInstanceUrl}/feed/playlist/${playlistId}` try { diff --git a/src/renderer/helpers/api/local.js b/src/renderer/helpers/api/local.js index 12ddbf6cb3fbf..ba9a260f7c3cf 100644 --- a/src/renderer/helpers/api/local.js +++ b/src/renderer/helpers/api/local.js @@ -8,6 +8,7 @@ import { calculatePublishedDate, escapeHTML, extractNumberFromString, + getChannelPlaylistId, randomArrayItem, toLocalePublicationString } from '../utils' @@ -397,6 +398,21 @@ export async function getLocalChannelVideos(id) { // so we need to check that we got the right tab if (videosTab.current_tab?.endpoint.metadata.url?.endsWith('/videos')) { videos = parseLocalChannelVideos(videosTab.videos, channelId, name) + } else if (name.endsWith('- Topic') && !!videosTab.metadata.music_artist_name) { + try { + const playlist = await innertube.getPlaylist(getChannelPlaylistId(channelId, 'videos', 'newest')) + + videos = playlist.items.map(parseLocalPlaylistVideo) + } catch (error) { + // If the channel doesn't exist, the API call to channel page above would have already failed, + // so if we get an error that the playlist doesn't exist here, it just means that this artist topic channel + // doesn't have any videos. + if (error.message === 'The playlist does not exist.') { + videos = [] + } else { + throw error + } + } } else { videos = [] } @@ -922,6 +938,7 @@ export function parseLocalPlaylistVideo(video) { ) return { + type: 'video', videoId: video_.id, title: video_.title.text, author: video_.author.name, diff --git a/src/renderer/helpers/utils.js b/src/renderer/helpers/utils.js index cc02b4c60d9c2..08203d0b6ca24 100644 --- a/src/renderer/helpers/utils.js +++ b/src/renderer/helpers/utils.js @@ -852,3 +852,44 @@ export function base64EncodeUtf8(text) { const binString = Array.from(bytes, (byte) => String.fromCodePoint(byte)).join('') return btoa(binString) } + +/** + * @overload + * @param {string} channelId + * @param {'videos' | 'live' | 'shorts'} type + * @param {'newest' | 'popular'} sortBy + * @returns {string} + * + * @overload + * @param {string} channelId + * @param {'all'} type + * @returns {string} +* + * @param {string} channelId + * @param {'all' | 'videos' | 'live' | 'shorts'} type + * @param {'newest' | 'popular'} sortBy + */ +export function getChannelPlaylistId(channelId, type, sortBy) { + switch (type) { + case 'videos': + if (sortBy === 'popular') { + return channelId.replace(/^UC/, 'UULP') + } else { + return channelId.replace(/^UC/, 'UULF') + } + case 'live': + if (sortBy === 'popular') { + return channelId.replace(/^UC/, 'UULV') + } else { + return channelId.replace(/^UC/, 'UUPV') + } + case 'shorts': + if (sortBy === 'popular') { + return channelId.replace(/^UC/, 'UUPS') + } else { + return channelId.replace(/^UC/, 'UUSH') + } + case 'all': + return channelId.replace(/^UC/, 'UU') + } +} diff --git a/src/renderer/views/Channel/Channel.js b/src/renderer/views/Channel/Channel.js index 8116dbb705fe6..d0a1b68d45b15 100644 --- a/src/renderer/views/Channel/Channel.js +++ b/src/renderer/views/Channel/Channel.js @@ -16,6 +16,7 @@ import { copyToClipboard, extractNumberFromString, showToast, + getChannelPlaylistId, getIconForSortPreference } from '../../helpers/utils' import { isNullOrEmpty } from '../../helpers/strings' @@ -39,7 +40,9 @@ import { parseLocalListPlaylist, parseLocalListVideo, parseLocalSubscriberCount, - getLocalArtistTopicChannelReleasesContinuation + getLocalArtistTopicChannelReleasesContinuation, + getLocalPlaylist, + parseLocalPlaylistVideo } from '../../helpers/api/local' export default defineComponent({ @@ -107,11 +110,6 @@ export default defineComponent({ errorMessage: '', showSearchBar: true, showShareMenu: true, - videoLiveShortSelectValues: [ - 'newest', - 'popular', - 'oldest' - ], playlistSelectValues: [ 'newest', 'last' @@ -178,7 +176,29 @@ export default defineComponent({ return profileList[0].subscriptions.some((channel) => channel.id === this.id) }, + videoLiveShortSelectValues: function () { + if (this.isArtistTopicChannel) { + return [ + 'newest', + 'popular', + ] + } + + return [ + 'newest', + 'popular', + 'oldest' + ] + }, + videoLiveShortSelectNames: function () { + if (this.isArtistTopicChannel) { + return [ + this.$t('Channel.Videos.Sort Types.Newest'), + this.$t('Channel.Videos.Sort Types.Most Popular'), + ] + } + return [ this.$t('Channel.Videos.Sort Types.Newest'), this.$t('Channel.Videos.Sort Types.Most Popular'), @@ -617,7 +637,7 @@ export default defineComponent({ } const tabs = ['about'] - if (channel.has_videos) { + if (channel.has_videos || this.isArtistTopicChannel) { tabs.push('videos') this.getChannelVideosLocal() } @@ -691,7 +711,7 @@ export default defineComponent({ } }, - getChannelAboutLocal: async function (channel) { + getChannelAboutLocal: async function () { try { /** * @type {import('youtubei.js').YT.Channel} @@ -749,26 +769,43 @@ export default defineComponent({ const expectedId = this.id try { - /** - * @type {import('youtubei.js').YT.Channel} - */ - const channel = this.channelInstance - let videosTab = await channel.getVideos() + if (this.isArtistTopicChannel) { + // Artist topic channels don't have a videos tab. + // Interestingly the auto-generated uploads playlists do exist for those channels, + // so we'll use them instead. - this.showVideoSortBy = videosTab.filters.length > 1 + const playlistId = getChannelPlaylistId(this.id, 'videos', this.videoSortBy) + const playlist = await getLocalPlaylist(playlistId) - if (this.showVideoSortBy && this.videoSortBy !== 'newest') { - const index = this.videoLiveShortSelectValues.indexOf(this.videoSortBy) - videosTab = await videosTab.applyFilter(videosTab.filters[index]) - } + if (expectedId !== this.id) { + return + } - if (expectedId !== this.id) { - return - } + this.latestVideos = playlist.items.map(parseLocalPlaylistVideo) + this.videoContinuationData = playlist.has_continuation ? playlist : null + this.isElementListLoading = false + } else { + /** + * @type {import('youtubei.js').YT.Channel} + */ + const channel = this.channelInstance + let videosTab = await channel.getVideos() - this.latestVideos = parseLocalChannelVideos(videosTab.videos, this.id, this.channelName) - this.videoContinuationData = videosTab.has_continuation ? videosTab : null - this.isElementListLoading = false + this.showVideoSortBy = videosTab.filters.length > 1 + + if (this.showVideoSortBy && this.videoSortBy !== 'newest') { + const index = this.videoLiveShortSelectValues.indexOf(this.videoSortBy) + videosTab = await videosTab.applyFilter(videosTab.filters[index]) + } + + if (expectedId !== this.id) { + return + } + + this.latestVideos = parseLocalChannelVideos(videosTab.videos, this.id, this.channelName) + this.videoContinuationData = videosTab.has_continuation ? videosTab : null + this.isElementListLoading = false + } if (this.isSubscribedInAnyProfile && this.latestVideos.length > 0 && this.videoSortBy === 'newest') { this.updateSubscriptionVideosCacheByChannel({ @@ -780,6 +817,11 @@ export default defineComponent({ }) } } catch (err) { + if (this.isArtistTopicChannel && err.message === 'The playlist does not exist.') { + // If this artist topic channel doesn't have any videos, ignore the error. + return + } + console.error(err) const errorMessage = this.$t('Local API Error (Click to copy)') showToast(`${errorMessage}: ${err}`, 10000, () => { @@ -796,13 +838,21 @@ export default defineComponent({ channelLocalNextPage: async function () { try { - /** - * @type {import('youtubei.js').YT.ChannelListContinuation|import('youtubei.js').YT.FilteredChannelList} - */ - const continuation = await this.videoContinuationData.getContinuation() + if (this.isArtistTopicChannel) { + /** @type {import('youtubei.js').YT.Playlist} */ + const continuation = await this.videoContinuationData.getContinuation() + + this.latestVideos = this.latestVideos.concat(continuation.items.map(parseLocalPlaylistVideo)) + this.videoContinuationData = continuation.has_continuation ? continuation : null + } else { + /** + * @type {import('youtubei.js').YT.ChannelListContinuation|import('youtubei.js').YT.FilteredChannelList} + */ + const continuation = await this.videoContinuationData.getContinuation() - this.latestVideos = this.latestVideos.concat(parseLocalChannelVideos(continuation.videos, this.id, this.channelName)) - this.videoContinuationData = continuation.has_continuation ? continuation : null + this.latestVideos = this.latestVideos.concat(parseLocalChannelVideos(continuation.videos, this.id, this.channelName)) + this.videoContinuationData = continuation.has_continuation ? continuation : null + } } catch (err) { console.error(err) const errorMessage = this.$t('Local API Error (Click to copy)')