From 0b9dde9e4c3944825fcfe59196d98a2efae63729 Mon Sep 17 00:00:00 2001 From: laclcia Date: Tue, 19 May 2026 13:31:25 -0400 Subject: [PATCH] feat: Add fallback behavior for RSS ratelimiting #8653 --- .../SubscriptionSettings.vue | 17 +++++++ src/renderer/components/SubscriptionsLive.vue | 47 +++++++++++++++---- .../components/SubscriptionsVideos.vue | 47 +++++++++++++++---- src/renderer/store/modules/settings.js | 1 + .../views/Subscriptions/Subscriptions.vue | 7 ++- static/locales/en-US.yaml | 5 ++ 6 files changed, 103 insertions(+), 21 deletions(-) diff --git a/src/renderer/components/SubscriptionSettings/SubscriptionSettings.vue b/src/renderer/components/SubscriptionSettings/SubscriptionSettings.vue index 2e7e6f6935816..0bb0b21132619 100644 --- a/src/renderer/components/SubscriptionSettings/SubscriptionSettings.vue +++ b/src/renderer/components/SubscriptionSettings/SubscriptionSettings.vue @@ -24,6 +24,13 @@ compact @change="updateUnsubscriptionPopupStatus" /> +
} */ +const limitRequestFallbackWithoutRss = computed(() => store.getters.getLimitRequestFallbackWithoutRss) + +/** + * @param {boolean} value + */ +function updateLimitRequestFallbackWithoutRss(value) { + store.dispatch('updateLimitRequestFallbackWithoutRss', value) +} + /** @type {import('vue').ComputedRef} */ const onlyShowLatestFromChannel = computed(() => store.getters.getOnlyShowLatestFromChannel) diff --git a/src/renderer/components/SubscriptionsLive.vue b/src/renderer/components/SubscriptionsLive.vue index 4bea31baf8386..7be9e9e9732bc 100644 --- a/src/renderer/components/SubscriptionsLive.vue +++ b/src/renderer/components/SubscriptionsLive.vue @@ -54,6 +54,9 @@ const subscriptionCacheReady = computed(() => store.getters.getSubscriptionCache /** @type {import('vue').ComputedRef} */ const useRssFeeds = computed(() => store.getters.getUseRssFeeds) +/** @type {import('vue').ComputedRef} */ +const limitRequestFallbackWithoutRss = computed(() => store.getters.getLimitRequestFallbackWithoutRss) + /** @type {import('vue').ComputedRef} */ const fetchSubscriptionsAutomatically = computed(() => store.getters.getFetchSubscriptionsAutomatically) @@ -185,14 +188,22 @@ async function loadVideosForSubscriptionsFromRemote() { let channelCount = 0 isLoading.value = true - let useRss = useRssFeeds.value - if (channelsToLoadFromRemote.length >= 125 && !useRss) { - showToast( - t('Subscriptions["This profile has a large number of subscriptions. Forcing RSS to avoid rate limiting"]'), - 10000 - ) - useRss = true - } + const useRss = limitRequestFallbackWithoutRss.value + ? useRssFeeds.value + : (() => { + let rss = useRssFeeds.value + if (channelsToLoadFromRemote.length >= 125 && !rss) { + showToast( + t('Subscriptions["This profile has a large number of subscriptions. Forcing RSS to avoid rate limiting"]'), + 10000 + ) + rss = true + } + return rss + })() + + const CHUNK_SIZE = 80 + const CHUNK_DELAY_MS = 2000 store.commit('setShowProgressBar', true) store.commit('setProgressBarPercentage', 0) @@ -200,8 +211,9 @@ async function loadVideosForSubscriptionsFromRemote() { errorChannels.value = [] const subscriptionUpdates = [] + const videoListFromRemote = [] - const videoListFromRemote = (await Promise.all(channelsToLoadFromRemote.map(async (channel) => { + const processChannel = async (channel) => { let videos, name, thumbnailUrl if (!process.env.SUPPORTS_LOCAL_API || backendPreference.value === 'invidious') { @@ -238,7 +250,22 @@ async function loadVideosForSubscriptionsFromRemote() { } return videos ?? [] - }))).flat() + } + + if (limitRequestFallbackWithoutRss.value && !useRss) { + for (let i = 0; i < channelsToLoadFromRemote.length; i += CHUNK_SIZE) { + if (i > 0) { + await new Promise(resolve => setTimeout(resolve, CHUNK_DELAY_MS)) + } + + const chunk = channelsToLoadFromRemote.slice(i, i + CHUNK_SIZE) + const chunkResults = await Promise.all(chunk.map(processChannel)) + videoListFromRemote.push(...chunkResults.flat()) + } + } else { + const results = await Promise.all(channelsToLoadFromRemote.map(processChannel)) + videoListFromRemote.push(...results.flat()) + } videoList.value = updateVideoListAfterProcessing(videoListFromRemote) isLoading.value = false diff --git a/src/renderer/components/SubscriptionsVideos.vue b/src/renderer/components/SubscriptionsVideos.vue index 049a1741a07fd..7d8afc9fbdefe 100644 --- a/src/renderer/components/SubscriptionsVideos.vue +++ b/src/renderer/components/SubscriptionsVideos.vue @@ -54,6 +54,9 @@ const subscriptionCacheReady = computed(() => store.getters.getSubscriptionCache /** @type {import('vue').ComputedRef} */ const useRssFeeds = computed(() => store.getters.getUseRssFeeds) +/** @type {import('vue').ComputedRef} */ +const limitRequestFallbackWithoutRss = computed(() => store.getters.getLimitRequestFallbackWithoutRss) + /** @type {import('vue').ComputedRef} */ const fetchSubscriptionsAutomatically = computed(() => store.getters.getFetchSubscriptionsAutomatically) @@ -184,14 +187,22 @@ async function loadVideosForSubscriptionsFromRemote() { let channelCount = 0 isLoading.value = true - let useRss = useRssFeeds.value - if (channelsToLoadFromRemote.length >= 125 && !useRss) { - showToast( - t('Subscriptions["This profile has a large number of subscriptions. Forcing RSS to avoid rate limiting"]'), - 10000 - ) - useRss = true - } + const useRss = limitRequestFallbackWithoutRss.value + ? useRssFeeds.value + : (() => { + let rss = useRssFeeds.value + if (channelsToLoadFromRemote.length >= 125 && !rss) { + showToast( + t('Subscriptions["This profile has a large number of subscriptions. Forcing RSS to avoid rate limiting"]'), + 10000 + ) + rss = true + } + return rss + })() + + const CHUNK_SIZE = 80 + const CHUNK_DELAY_MS = 2000 store.commit('setShowProgressBar', true) store.commit('setProgressBarPercentage', 0) @@ -199,8 +210,9 @@ async function loadVideosForSubscriptionsFromRemote() { errorChannels.value = [] const subscriptionUpdates = [] + const videoListFromRemote = [] - const videoListFromRemote = (await Promise.all(channelsToLoadFromRemote.map(async (channel) => { + const processChannel = async (channel) => { let videos, name, thumbnailUrl if (!process.env.SUPPORTS_LOCAL_API || backendPreference.value === 'invidious') { @@ -237,7 +249,22 @@ async function loadVideosForSubscriptionsFromRemote() { } return videos ?? [] - }))).flat() + } + + if (limitRequestFallbackWithoutRss.value && !useRss) { + for (let i = 0; i < channelsToLoadFromRemote.length; i += CHUNK_SIZE) { + if (i > 0) { + await new Promise(resolve => setTimeout(resolve, CHUNK_DELAY_MS)) + } + + const chunk = channelsToLoadFromRemote.slice(i, i + CHUNK_SIZE) + const chunkResults = await Promise.all(chunk.map(processChannel)) + videoListFromRemote.push(...chunkResults.flat()) + } + } else { + const results = await Promise.all(channelsToLoadFromRemote.map(processChannel)) + videoListFromRemote.push(...results.flat()) + } videoList.value = updateVideoListAfterProcessing(videoListFromRemote) isLoading.value = false diff --git a/src/renderer/store/modules/settings.js b/src/renderer/store/modules/settings.js index c0915813b923b..27e7b8b7b9e62 100644 --- a/src/renderer/store/modules/settings.js +++ b/src/renderer/store/modules/settings.js @@ -280,6 +280,7 @@ const state = { useProxy: false, userPlaylistSortOrder: 'date_added_descending', useRssFeeds: false, + limitRequestFallbackWithoutRss: false, useSponsorBlock: false, videoVolumeMouseScroll: false, videoPlaybackRateMouseScroll: false, diff --git a/src/renderer/views/Subscriptions/Subscriptions.vue b/src/renderer/views/Subscriptions/Subscriptions.vue index 6f36d1111e011..18a71be771847 100644 --- a/src/renderer/views/Subscriptions/Subscriptions.vue +++ b/src/renderer/views/Subscriptions/Subscriptions.vue @@ -167,6 +167,11 @@ const useRssFeeds = computed(() => { return store.getters.getUseRssFeeds }) +/** @type {import('vue').ComputedRef} */ +const limitRequestFallbackWithoutRss = computed(() => { + return store.getters.getLimitRequestFallbackWithoutRss +}) + /** @type {import('vue').Ref<'videos' | 'shorts' | 'live' | 'community' | null>} */ const currentTab = ref('videos') @@ -196,7 +201,7 @@ const visibleTabs = computed(() => { } // community does not support rss - if (!hideSubscriptionsCommunity.value && !useRssFeeds.value && activeSubscriptionList.value.length < 125) { + if (!hideSubscriptionsCommunity.value && !useRssFeeds.value && (limitRequestFallbackWithoutRss.value || activeSubscriptionList.value.length < 125)) { tabs.push('community') } diff --git a/static/locales/en-US.yaml b/static/locales/en-US.yaml index 8bbf28982fa0f..cbc883dce06e5 100644 --- a/static/locales/en-US.yaml +++ b/static/locales/en-US.yaml @@ -537,6 +537,7 @@ Settings: 'Limit the number of videos displayed for each channel': 'Limit the number of videos displayed for each channel' To: To Confirm Before Unsubscribing: Confirm Before Unsubscribing + Limit Request Fallback Without RSS: Limit Request Fallback Without RSS Distraction Free Settings: Distraction Free Settings: Distraction Free Sections: @@ -1086,6 +1087,10 @@ Tooltips: but doesn't provide certain information like video duration, live status or posts Fetch Automatically: When enabled, FreeTube will automatically fetch your subscription feed on startup and when a new window is opened. + Limit Request Fallback Without RSS: When enabled, FreeTube will split + subscription requests into smaller chunks instead of forcing RSS for large + subscription lists. This prevents rate limiting while preserving video duration, + live status and other metadata that RSS does not provide Experimental Settings: Replace HTTP Cache: Disables Electron's disk based HTTP cache and enables a custom in-memory image cache. Will lead to increased RAM usage. SponsorBlock Settings: