diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 0fc72d5eb..920f13576 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -87,7 +87,12 @@ export default { lastTimestamp: 0, postScrollTimeout: null, currFirstEntityIndex: -1, - currLastEntityIndex: -1 + currLastEntityIndex: -1, + initialPagesToLoad: 10, + maxConcurrentRequests: 3, + preloadThreshold: 0.6, + isProgressiveLoading: false, + progressiveLoadProgress: 0 } }, watch: { @@ -386,34 +391,44 @@ export default { return } if (payload) { - this.paginationMode = payload.paginationMode || this.paginationMode - this.nextCursor = payload.nextCursor || null - this.isCountDeferred = !!payload.isCountDeferred - - if (this.paginationMode === 'keyset') { - if (this.nextCursor) { - this.pageCursors[page + 1] = this.nextCursor - } else { - delete this.pageCursors[page + 1] + try { + const results = Array.isArray(payload.results) ? payload.results : null + if (!results) { + throw new Error('Invalid browse payload: missing results array') } - } - if (!this.initialized) { - this.initialized = true - this.totalEntities = payload.total - this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf) - this.entities = new Array(this.totalEntities) - } + this.paginationMode = payload.paginationMode || this.paginationMode + this.nextCursor = payload.nextCursor || null + this.isCountDeferred = !!payload.isCountDeferred - for (let i = 0; i < payload.results.length; i++) { - const index = i + startIndex - this.entities[index] = payload.results[i] - if (this.entityComponentRefs[index]) { - this.entityComponentRefs[index].setEntity(this.entities[index]) + if (this.paginationMode === 'keyset') { + if (this.nextCursor) { + this.pageCursors[page + 1] = this.nextCursor + } else { + delete this.pageCursors[page + 1] + } } - } - this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities) + if (!this.initialized) { + this.initialized = true + this.totalEntities = payload.total + this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf) + this.entities = new Array(this.totalEntities) + } + + for (let i = 0; i < results.length; i++) { + const index = i + startIndex + this.entities[index] = results[i] + if (this.entityComponentRefs[index]) { + this.entityComponentRefs[index].setEntity(this.entities[index]) + } + } + + this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities) + } catch (error) { + console.error(`Failed to process page ${page}`, error) + return null + } } }, loadPage(page) { @@ -428,8 +443,68 @@ export default { this.pagesLoaded[page] = this.fetchEntites(page) } + this.pagesLoaded[page] = this.pagesLoaded[page].catch((error) => { + delete this.pagesLoaded[page] + throw error + }) + return this.pagesLoaded[page] }, + async loadPagesWithConcurrency(pages, concurrency = this.maxConcurrentRequests) { + if (this.paginationMode === 'keyset') { + let completed = 0 + for (const page of pages) { + try { + await this.loadPage(page) + } catch (error) { + console.error(`Failed to load page ${page}:`, error) + break + } finally { + completed++ + this.progressiveLoadProgress = Math.round((completed / pages.length) * 100) + } + } + return + } + + const queue = [...pages] + const workers = Array.from({ length: Math.min(concurrency, pages.length) }, async () => { + while (queue.length) { + const page = queue.shift() + try { + await this.loadPage(page) + } catch (error) { + console.error(`Failed to load page ${page}:`, error) + } finally { + const completed = pages.length - queue.length + this.progressiveLoadProgress = Math.round((completed / pages.length) * 100) + } + } + }) + + await Promise.all(workers) + }, + async progressiveInitialLoad() { + if (this.totalEntities === 0) return + + const totalPages = Math.ceil(this.totalEntities / this.booksPerFetch) + const pagesToLoad = Math.min(this.initialPagesToLoad, totalPages) + if (pagesToLoad <= 1) return + + this.isProgressiveLoading = true + this.progressiveLoadProgress = 0 + + const additionalPages = Array.from({ length: pagesToLoad - 1 }, (_, i) => i + 1) + + try { + await this.loadPagesWithConcurrency(additionalPages) + } catch (error) { + console.error('Failed progressive load', error) + } finally { + this.isProgressiveLoading = false + this.progressiveLoadProgress = 100 + } + }, showHideBookPlaceholder(index, show) { var el = document.getElementById(`book-${index}-placeholder`) if (el) el.style.display = show ? 'flex' : 'none' @@ -824,6 +899,7 @@ export default { await this.loadPage(0) var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf) this.mountEntities(0, lastBookIndex) + this.progressiveInitialLoad().catch((error) => console.error('Failed progressive load', error)) // Set last scroll position for this bookshelf page if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) { diff --git a/client/cypress/tests/components/app/LazyBookshelf.cy.js b/client/cypress/tests/components/app/LazyBookshelf.cy.js index 01c71101e..e073e93d8 100644 --- a/client/cypress/tests/components/app/LazyBookshelf.cy.js +++ b/client/cypress/tests/components/app/LazyBookshelf.cy.js @@ -1,7 +1,88 @@ import LazyBookshelf from '@/components/app/LazyBookshelf.vue' describe('LazyBookshelf', () => { - it('uses the server cursor for keyset follow-up chunks', () => { + const createStore = () => ({ + getters: { + 'user/getIsAdminOrUp': true, + 'libraries/getCurrentLibraryMediaType': 'book', + 'libraries/getCurrentLibraryName': 'Library', + 'user/getUserSetting': (key) => { + const settings = { + orderBy: 'media.metadata.title', + orderDesc: false, + filterBy: 'all', + collapseSeries: false, + collapseBookSeries: false, + seriesSortBy: 'name', + seriesSortDesc: false, + seriesFilterBy: 'all', + authorSortBy: 'name', + authorSortDesc: false + } + return settings[key] + }, + 'libraries/getBookCoverAspectRatio': 1.6, + 'getServerSetting': () => false, + getBookshelfView: 'grid', + 'user/getSizeMultiplier': 1, + 'tasks/getRunningLibraryScanTask': () => null + }, + state: { + libraries: { + currentLibraryId: 'lib-1' + }, + globals: { + selectedMediaItems: [] + }, + streamLibraryItem: null, + lastBookshelfScrollData: {} + }, + dispatch: cy.stub().resolves(), + commit: cy.stub() + }) + + const mountBookshelf = (getStub, data = {}) => { + return cy.mount(LazyBookshelf, { + mocks: { + $axios: { $get: getStub }, + $store: createStore(), + $eventBus: { $on: () => {}, $off: () => {}, $emit: () => {} }, + $root: { socket: { on: () => {}, off: () => {} } }, + $route: { query: {} }, + $router: { push: cy.stub() }, + $toast: { error: cy.stub() }, + $getString: (key) => key, + $encode: encodeURIComponent, + $decode: decodeURIComponent + }, + methods: { + async setCardSize() { + this.cardWidth = 120 + this.cardHeight = 180 + this.coverHeight = 160 + }, + mountEntityCard() {}, + initSizeData() { + this.bookshelfHeight = 600 + this.bookshelfWidth = 600 + this.entitiesPerShelf = 1 + this.shelvesPerPage = 1 + this.booksPerFetch = 1 + this.bookshelfMarginLeft = 0 + this.currentBookWidth = this.bookWidth + return false + } + }, + data() { + return { + initialPagesToLoad: 2, + ...data + } + } + }) + } + + it('uses the server cursor for mounted keyset follow-up chunks', () => { const requestUrls = [] const getStub = cy.stub().callsFake((url) => { requestUrls.push(url) @@ -25,84 +106,47 @@ describe('LazyBookshelf', () => { }) }) - const store = { - getters: { - 'user/getIsAdminOrUp': true, - 'libraries/getCurrentLibraryMediaType': 'book', - 'libraries/getCurrentLibraryName': 'Library', - 'user/getUserSetting': (key) => { - const settings = { - orderBy: 'media.metadata.title', - orderDesc: false, - filterBy: 'all', - collapseSeries: false, - collapseBookSeries: false, - seriesSortBy: 'name', - seriesSortDesc: false, - seriesFilterBy: 'all', - authorSortBy: 'name', - authorSortDesc: false - } - return settings[key] - }, - 'libraries/getBookCoverAspectRatio': 1.6, - 'getServerSetting': () => false, - getBookshelfView: 'grid', - 'user/getSizeMultiplier': 1, - 'tasks/getRunningLibraryScanTask': () => null - }, - state: { - libraries: { - currentLibraryId: 'lib-1' - }, - globals: { - selectedMediaItems: [] - }, - streamLibraryItem: null, - lastBookshelfScrollData: {} - }, - dispatch: cy.stub().resolves(), - commit: cy.stub() - } + mountBookshelf(getStub).then(({ wrapper }) => { + cy.wrap(getStub).should('have.been.calledTwice').then(() => { + expect(wrapper.vm.pagesLoaded[1]).to.be.ok + expect(wrapper.vm.pageCursors[1]).to.equal('cursor-1') + expect(wrapper.vm.paginationMode).to.equal('keyset') + expect(wrapper.vm.isProgressiveLoading).to.equal(false) + expect(requestUrls).to.have.length(2) + expect(requestUrls[0]).to.include('page=0') + expect(requestUrls[0]).to.not.include('cursor=') + expect(requestUrls[1]).to.include('cursor=cursor-1') + expect(requestUrls[1]).to.not.include('page=1') + }) + }) + }) - cy.mount(LazyBookshelf, { - mocks: { - $axios: { $get: getStub }, - $store: store, - $eventBus: { $on: () => {}, $off: () => {}, $emit: () => {} }, - $root: { socket: { on: () => {}, off: () => {} } }, - $route: { query: {} }, - $router: { push: cy.stub() }, - $toast: { error: cy.stub() }, - $getString: (key) => key, - $encode: encodeURIComponent, - $decode: decodeURIComponent - }, - methods: { - async setCardSize() { - this.cardWidth = 120 - this.cardHeight = 180 - this.coverHeight = 160 - }, - mountEntityCard() {}, - initListeners() {} - }, - data() { - return { - booksPerFetch: 1, - entitiesPerShelf: 1, - shelvesPerPage: 1 - } - } - }).then(async ({ wrapper }) => { - await wrapper.vm.fetchEntites(0) - await wrapper.vm.fetchEntites(1) + it('clears progressive loading state when a keyset follow-up chunk fails', () => { + const getStub = cy.stub() - expect(requestUrls).to.have.length(2) - expect(requestUrls[0]).to.include('page=0') - expect(requestUrls[0]).to.not.include('cursor=') - expect(requestUrls[1]).to.include('cursor=cursor-1') - expect(requestUrls[1]).to.not.include('page=1') + getStub.onFirstCall().resolves({ + results: [{ id: 'item-1', mediaType: 'book', media: { metadata: { title: 'Alpha' } } }], + total: 4, + nextCursor: 'cursor-1', + paginationMode: 'keyset', + isCountDeferred: true + }) + getStub.onSecondCall().resolves({ + total: 4, + nextCursor: null, + paginationMode: 'keyset', + isCountDeferred: true + }) + + cy.spy(console, 'error').as('consoleError') + + mountBookshelf(getStub).then(({ wrapper }) => { + cy.wrap(getStub).should('have.been.calledTwice') + cy.get('@consoleError').should('have.been.called') + cy.wrap(null).then(() => { + expect(wrapper.vm.isProgressiveLoading).to.equal(false) + expect(wrapper.vm.progressiveLoadProgress).to.equal(100) + }) }) }) })