import { defineComponent } from 'vue' import { mapActions, mapMutations } from 'vuex' import SubscriptionsTabUI from '../subscriptions-tab-ui/subscriptions-tab-ui.vue' import { setPublishedTimestampsInvidious, copyToClipboard, showToast } from '../../helpers/utils' import { invidiousAPICall } from '../../helpers/api/invidious' import { getLocalChannelVideos } from '../../helpers/api/local' import { parseYouTubeRSSFeed, updateVideoListAfterProcessing } from '../../helpers/subscriptions' export default defineComponent({ name: 'SubscriptionsVideos', components: { 'subscriptions-tab-ui': SubscriptionsTabUI }, data: function () { return { isLoading: false, videoList: [], errorChannels: [], attemptedFetch: false, } }, computed: { backendPreference: function () { return this.$store.getters.getBackendPreference }, backendFallback: function () { return this.$store.getters.getBackendFallback }, currentInvidiousInstance: function () { return this.$store.getters.getCurrentInvidiousInstance }, useRssFeeds: function () { return this.$store.getters.getUseRssFeeds }, activeProfile: function () { return this.$store.getters.getActiveProfile }, activeProfileId: function () { return this.activeProfile._id }, cacheEntriesForAllActiveProfileChannels() { const entries = [] this.activeSubscriptionList.forEach((channel) => { const cacheEntry = this.$store.getters.getVideoCacheByChannel(channel.id) if (cacheEntry == null) { return } entries.push(cacheEntry) }) return entries }, videoCacheForAllActiveProfileChannelsPresent() { if (this.cacheEntriesForAllActiveProfileChannels.length === 0) { return false } if (this.cacheEntriesForAllActiveProfileChannels.length < this.activeSubscriptionList.length) { return false } return this.cacheEntriesForAllActiveProfileChannels.every((cacheEntry) => { return cacheEntry.videos != null }) }, activeSubscriptionList: function () { return this.activeProfile.subscriptions }, fetchSubscriptionsAutomatically: function() { return this.$store.getters.getFetchSubscriptionsAutomatically }, }, watch: { activeProfile: async function (_) { this.isLoading = true this.loadVideosFromCacheSometimes() }, }, mounted: async function () { this.isLoading = true this.loadVideosFromCacheSometimes() }, methods: { loadVideosFromCacheSometimes() { // This method is called on view visible if (this.videoCacheForAllActiveProfileChannelsPresent) { this.loadVideosFromCacheForAllActiveProfileChannels() return } this.maybeLoadVideosForSubscriptionsFromRemote() }, async loadVideosFromCacheForAllActiveProfileChannels() { const videoList = [] this.activeSubscriptionList.forEach((channel) => { const channelCacheEntry = this.$store.getters.getVideoCacheByChannel(channel.id) videoList.push(...channelCacheEntry.videos) }) this.videoList = updateVideoListAfterProcessing(videoList) this.isLoading = false }, loadVideosForSubscriptionsFromRemote: async function () { if (this.activeSubscriptionList.length === 0) { this.isLoading = false this.videoList = [] return } const channelsToLoadFromRemote = this.activeSubscriptionList const videoList = [] let channelCount = 0 this.isLoading = true let useRss = this.useRssFeeds if (channelsToLoadFromRemote.length >= 125 && !useRss) { showToast( this.$t('Subscriptions["This profile has a large number of subscriptions. Forcing RSS to avoid rate limiting"]'), 10000 ) useRss = true } this.updateShowProgressBar(true) this.setProgressBarPercentage(0) this.attemptedFetch = true this.errorChannels = [] const subscriptionUpdates = [] const videoListFromRemote = (await Promise.all(channelsToLoadFromRemote.map(async (channel) => { let videos = [] let name, thumbnailUrl if (!process.env.SUPPORTS_LOCAL_API || this.backendPreference === 'invidious') { if (useRss) { ({ videos, name, thumbnailUrl } = await this.getChannelVideosInvidiousRSS(channel)) } else { ({ videos, name, thumbnailUrl } = await this.getChannelVideosInvidiousScraper(channel)) } } else { if (useRss) { ({ videos, name, thumbnailUrl } = await this.getChannelVideosLocalRSS(channel)) } else { ({ videos, name, thumbnailUrl } = await this.getChannelVideosLocalScraper(channel)) } } channelCount++ const percentageComplete = (channelCount / channelsToLoadFromRemote.length) * 100 this.setProgressBarPercentage(percentageComplete) this.updateSubscriptionVideosCacheByChannel({ channelId: channel.id, videos: videos, }) if (name || thumbnailUrl) { subscriptionUpdates.push({ channelId: channel.id, channelName: name, channelThumbnailUrl: thumbnailUrl }) } return videos }))).flatMap((o) => o) videoList.push(...videoListFromRemote) this.videoList = updateVideoListAfterProcessing(videoList) this.isLoading = false this.updateShowProgressBar(false) this.batchUpdateSubscriptionDetails(subscriptionUpdates) }, maybeLoadVideosForSubscriptionsFromRemote: async function () { if (this.fetchSubscriptionsAutomatically) { // `this.isLoading = false` is called inside `loadVideosForSubscriptionsFromRemote` when needed await this.loadVideosForSubscriptionsFromRemote() } else { this.videoList = [] this.attemptedFetch = false this.isLoading = false } }, getChannelVideosLocalScraper: async function (channel, failedAttempts = 0) { try { const result = await getLocalChannelVideos(channel.id) if (result === null) { this.errorChannels.push(channel) return { videos: [] } } return result } catch (err) { console.error(err) const errorMessage = this.$t('Local API Error (Click to copy)') showToast(`${errorMessage}: ${err}`, 10000, () => { copyToClipboard(err) }) switch (failedAttempts) { case 0: return await this.getChannelVideosLocalRSS(channel, failedAttempts + 1) case 1: if (this.backendFallback) { showToast(this.$t('Falling back to Invidious API')) return await this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1) } else { return { videos: [] } } case 2: return await this.getChannelVideosLocalRSS(channel, failedAttempts + 1) default: return { videos: [] } } } }, getChannelVideosLocalRSS: async function (channel, failedAttempts = 0) { const playlistId = channel.id.replace('UC', 'UULF') const feedUrl = `https://www.youtube.com/feeds/videos.xml?playlist_id=${playlistId}` try { const response = await fetch(feedUrl) if (response.status === 404) { // playlists don't exist if the channel was terminated but also if it doesn't have the tab, // so we need to check the channel feed too before deciding it errored, as that only 404s if the channel was terminated const response2 = await fetch(`https://www.youtube.com/feeds/videos.xml?channel_id=${channel.id}`, { method: 'HEAD' }) if (response2.status === 404) { this.errorChannels.push(channel) } return { videos: [] } } return await parseYouTubeRSSFeed(await response.text(), channel.id) } catch (error) { console.error(error) const errorMessage = this.$t('Local API Error (Click to copy)') showToast(`${errorMessage}: ${error}`, 10000, () => { copyToClipboard(error) }) switch (failedAttempts) { case 0: return this.getChannelVideosLocalScraper(channel, failedAttempts + 1) case 1: if (this.backendFallback) { showToast(this.$t('Falling back to Invidious API')) return this.getChannelVideosInvidiousRSS(channel, failedAttempts + 1) } else { return { videos: [] } } case 2: return this.getChannelVideosLocalScraper(channel, failedAttempts + 1) default: return { videos: [] } } } }, getChannelVideosInvidiousScraper: function (channel, failedAttempts = 0) { return new Promise((resolve, reject) => { const subscriptionsPayload = { resource: 'channels/latest', id: channel.id, params: {} } invidiousAPICall(subscriptionsPayload).then((result) => { setPublishedTimestampsInvidious(result.videos) let name if (result.videos.length > 0) { name = result.videos.find(video => video.type === 'video' && video.author).author } resolve({ name, videos: result.videos }) }).catch((err) => { console.error(err) const errorMessage = this.$t('Invidious API Error (Click to copy)') showToast(`${errorMessage}: ${err.responseText}`, 10000, () => { copyToClipboard(err.responseText) }) switch (failedAttempts) { case 0: resolve(this.getChannelVideosInvidiousRSS(channel, failedAttempts + 1)) break case 1: if (process.env.SUPPORTS_LOCAL_API && this.backendFallback) { showToast(this.$t('Falling back to Local API')) resolve(this.getChannelVideosLocalScraper(channel, failedAttempts + 1)) } else { resolve({ videos: [] }) } break case 2: resolve(this.getChannelVideosInvidiousRSS(channel, failedAttempts + 1)) break default: resolve({ videos: [] }) } }) }) }, getChannelVideosInvidiousRSS: async function (channel, failedAttempts = 0) { const playlistId = channel.id.replace('UC', 'UULF') const feedUrl = `${this.currentInvidiousInstance}/feed/playlist/${playlistId}` try { const response = await fetch(feedUrl) if (response.status === 500 || response.status === 404) { this.errorChannels.push(channel) return { videos: [] } } return await parseYouTubeRSSFeed(await response.text(), channel.id) } catch (error) { console.error(error) const errorMessage = this.$t('Invidious API Error (Click to copy)') showToast(`${errorMessage}: ${error}`, 10000, () => { copyToClipboard(error) }) switch (failedAttempts) { case 0: return this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1) case 1: if (process.env.SUPPORTS_LOCAL_API && this.backendFallback) { showToast(this.$t('Falling back to Local API')) return this.getChannelVideosLocalRSS(channel, failedAttempts + 1) } else { return { videos: [] } } case 2: return this.getChannelVideosInvidiousScraper(channel, failedAttempts + 1) default: return { videos: [] } } } }, ...mapActions([ 'batchUpdateSubscriptionDetails', 'updateShowProgressBar', 'updateSubscriptionVideosCacheByChannel', ]), ...mapMutations([ 'setProgressBarPercentage' ]) } })