diff --git a/src/renderer/components/WatchVideoPlaylist/WatchVideoPlaylist.vue b/src/renderer/components/WatchVideoPlaylist/WatchVideoPlaylist.vue index 56dd8a3e6ed91..7fd106d3b0336 100644 --- a/src/renderer/components/WatchVideoPlaylist/WatchVideoPlaylist.vue +++ b/src/renderer/components/WatchVideoPlaylist/WatchVideoPlaylist.vue @@ -174,7 +174,7 @@ import { parseLocalPlaylistVideo, untilEndOfLocalPlayList, } from '../../helpers/api/local' -import { invidiousGetPlaylistInfo } from '../../helpers/api/invidious' +import { invidiousGetPlaylistInfo, fetchAllInvidiousPlaylistVideos } from '../../helpers/api/invidious' import { getSortedPlaylistItems, SORT_BY_VALUES } from '../../helpers/playlists' const props = defineProps({ @@ -581,20 +581,22 @@ async function loadCachedPlaylistInformation(cachedPlaylist) { channelName.value = cachedPlaylist.channelName channelId.value = cachedPlaylist.channelId - if (!process.env.SUPPORTS_LOCAL_API || backendPreference.value === 'invidious' || cachedPlaylist.continuationData === null) { - playlistItems.value = cachedPlaylist.items - } else { - const videos = cachedPlaylist.items - + const videos = cachedPlaylist.items + if (!process.env.SUPPORTS_LOCAL_API || backendPreference.value === 'invidious') { + const videoCount = cachedPlaylist.videoCount + if (videos.length < videoCount) { + const remainingVideos = await fetchAllInvidiousPlaylistVideos(cachedPlaylist.id, videos.length) + videos.push(...remainingVideos) + } + } else if (cachedPlaylist.continuationData !== null) { const continuationData = await getLocalCachedFeedContinuation('playlist', cachedPlaylist.continuationData) videos.push(...continuationData.items.map(parseLocalPlaylistVideo)) await untilEndOfLocalPlayList(continuationData, (p) => { videos.push(...p.items.map(parseLocalPlaylistVideo)) }, { runCallbackOnceFirst: false }) - - playlistItems.value = videos } + playlistItems.value = videos isLoading.value = false } @@ -653,7 +655,11 @@ async function getPlaylistInformationInvidious() { channelName.value = result.author channelId.value = result.authorId - playlistItems.value = result.videos + const videos = result.videos + const remainingVideos = await fetchAllInvidiousPlaylistVideos(result.playlistId, videos.length) + videos.push(...remainingVideos) + + playlistItems.value = videos isLoading.value = false } catch (err) { diff --git a/src/renderer/helpers/api/invidious.js b/src/renderer/helpers/api/invidious.js index b4c6a0b79567f..c800c8ef6baed 100644 --- a/src/renderer/helpers/api/invidious.js +++ b/src/renderer/helpers/api/invidious.js @@ -284,6 +284,7 @@ export async function searchInvidiousChannel(channelId, query, page) { /** * @param {string} playlistId + * @param {number} index * @returns {Promise<{ * title: string, * playlistId: string, @@ -307,10 +308,17 @@ export async function searchInvidiousChannel(channelId, query, page) { * }[] * }>} */ -export async function invidiousGetPlaylistInfo(playlistId) { +export async function invidiousGetPlaylistInfo(playlistId, index = 0) { + // The Invidious API offsets the index by 50 to apply a lookback window + // By increasing our offset by 50, we skip those overlapping videos and avoid de-duplicating them from our response + // See: https://github.com/iv-org/invidious/blob/66fb829dbc0f96cbf4319798f3b9090bc88a7012/src/invidious/routes/api/v1/misc.cr#L67 + index += 50 const playlist = await invidiousAPICall({ resource: 'playlists', id: playlistId, + params: { + index + } }) normalizeManyInvidiousVideosAttributes(playlist.videos) @@ -319,6 +327,36 @@ export async function invidiousGetPlaylistInfo(playlistId) { return playlist } +/** + * @param {string} playlistId + * @param {number} initialOffset + * @returns {{ + * title: string, + * videoId: string, + * author: string, + * authorId: string, + * authorUrl: string, + * videoThumbnails: InvidiousThumbnailObject[], + * index: number, + * lengthSeconds: number + * }[]} + */ +export async function fetchAllInvidiousPlaylistVideos(playlistId, initialOffset = 0) { + let offset = initialOffset + const videos = [] + + while (true) { + const playlist = await invidiousGetPlaylistInfo(playlistId, offset) + if (playlist.videos.length === 0) { + break + } + videos.push(...playlist.videos) + offset += playlist.videos.length + } + + return videos +} + /** * @param {string} videoId * @returns {Promise<{ diff --git a/src/renderer/helpers/api/local.js b/src/renderer/helpers/api/local.js index 899ebb7dff049..b7a32ce2bfe35 100644 --- a/src/renderer/helpers/api/local.js +++ b/src/renderer/helpers/api/local.js @@ -286,7 +286,7 @@ export async function getLocalPlaylistContinuation(playlist) { } /** - * Callback for adding two numbers. + * Callback for processing a Local playlist. * * @callback untilEndOfLocalPlayListCallback * @param {import('youtubei.js').YT.Playlist} playlist diff --git a/src/renderer/views/Playlist/Playlist.vue b/src/renderer/views/Playlist/Playlist.vue index 2f26538b46234..4ca9c799a18b8 100644 --- a/src/renderer/views/Playlist/Playlist.vue +++ b/src/renderer/views/Playlist/Playlist.vue @@ -295,9 +295,15 @@ const selectedUserPlaylistVideos = computed(() => selectedUserPlaylist.value?.vi const selectedUserPlaylistVideoCount = computed(() => selectedUserPlaylistVideos.value.length) const moreVideoDataAvailable = computed(() => { - return isUserPlaylistRequested.value - ? userPlaylistVisibleLimit.value < sometimesFilteredUserPlaylistItems.value.length - : continuationData.value !== null + if (isUserPlaylistRequested.value) { + return userPlaylistVisibleLimit.value < sometimesFilteredUserPlaylistItems.value.length + } + + if (infoSource.value === 'invidious') { + return playlistItems.value.length < videoCount.value + } + + return continuationData.value !== null }) const processedVideoSearchQuery = computed(() => videoSearchQuery.value.trim().toLowerCase()) @@ -668,7 +674,7 @@ function getNextPage() { isLoadingMore.value = false }) } else if (infoSource.value === 'invidious') { - console.error('Playlist pagination is not currently supported when the Invidious backend is selected.') + getNextPageInvidious() } } @@ -703,6 +709,16 @@ async function getNextPageLocal() { } } +async function getNextPageInvidious() { + isLoadingMore.value = true + + const index = playlistItems.value.length + const result = await invidiousGetPlaylistInfo(playlistId.value, index) + playlistItems.value.push(...result.videos) + + isLoadingMore.value = false +} + const canMoveVideos = computed(() => { return !playlistInVideoSearchMode.value && isSortOrderCustom.value && noPlaylistItemsPendingDeletion.value }) @@ -1075,6 +1091,7 @@ onBeforeRouteLeave((to) => { continuationData: continuationData.value ? extractLocalCacheablePlaylistContinuation(continuationData.value) : null, + videoCount: videoCount.value, }) }