mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 09:51:37 +00:00
fix: harden cursor endless-scroll loading
This commit is contained in:
parent
706f219d17
commit
5c3e8559d4
2 changed files with 221 additions and 101 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue