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,
|
lastTimestamp: 0,
|
||||||
postScrollTimeout: null,
|
postScrollTimeout: null,
|
||||||
currFirstEntityIndex: -1,
|
currFirstEntityIndex: -1,
|
||||||
currLastEntityIndex: -1
|
currLastEntityIndex: -1,
|
||||||
|
initialPagesToLoad: 10,
|
||||||
|
maxConcurrentRequests: 3,
|
||||||
|
preloadThreshold: 0.6,
|
||||||
|
isProgressiveLoading: false,
|
||||||
|
progressiveLoadProgress: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
@ -386,34 +391,44 @@ export default {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (payload) {
|
if (payload) {
|
||||||
this.paginationMode = payload.paginationMode || this.paginationMode
|
try {
|
||||||
this.nextCursor = payload.nextCursor || null
|
const results = Array.isArray(payload.results) ? payload.results : null
|
||||||
this.isCountDeferred = !!payload.isCountDeferred
|
if (!results) {
|
||||||
|
throw new Error('Invalid browse payload: missing results array')
|
||||||
if (this.paginationMode === 'keyset') {
|
|
||||||
if (this.nextCursor) {
|
|
||||||
this.pageCursors[page + 1] = this.nextCursor
|
|
||||||
} else {
|
|
||||||
delete this.pageCursors[page + 1]
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.initialized) {
|
this.paginationMode = payload.paginationMode || this.paginationMode
|
||||||
this.initialized = true
|
this.nextCursor = payload.nextCursor || null
|
||||||
this.totalEntities = payload.total
|
this.isCountDeferred = !!payload.isCountDeferred
|
||||||
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
|
|
||||||
this.entities = new Array(this.totalEntities)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < payload.results.length; i++) {
|
if (this.paginationMode === 'keyset') {
|
||||||
const index = i + startIndex
|
if (this.nextCursor) {
|
||||||
this.entities[index] = payload.results[i]
|
this.pageCursors[page + 1] = this.nextCursor
|
||||||
if (this.entityComponentRefs[index]) {
|
} else {
|
||||||
this.entityComponentRefs[index].setEntity(this.entities[index])
|
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) {
|
loadPage(page) {
|
||||||
|
|
@ -428,8 +443,68 @@ export default {
|
||||||
this.pagesLoaded[page] = this.fetchEntites(page)
|
this.pagesLoaded[page] = this.fetchEntites(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.pagesLoaded[page] = this.pagesLoaded[page].catch((error) => {
|
||||||
|
delete this.pagesLoaded[page]
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
return this.pagesLoaded[page]
|
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) {
|
showHideBookPlaceholder(index, show) {
|
||||||
var el = document.getElementById(`book-${index}-placeholder`)
|
var el = document.getElementById(`book-${index}-placeholder`)
|
||||||
if (el) el.style.display = show ? 'flex' : 'none'
|
if (el) el.style.display = show ? 'flex' : 'none'
|
||||||
|
|
@ -824,6 +899,7 @@ export default {
|
||||||
await this.loadPage(0)
|
await this.loadPage(0)
|
||||||
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
||||||
this.mountEntities(0, lastBookIndex)
|
this.mountEntities(0, lastBookIndex)
|
||||||
|
this.progressiveInitialLoad().catch((error) => console.error('Failed progressive load', error))
|
||||||
|
|
||||||
// Set last scroll position for this bookshelf page
|
// Set last scroll position for this bookshelf page
|
||||||
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
|
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,88 @@
|
||||||
import LazyBookshelf from '@/components/app/LazyBookshelf.vue'
|
import LazyBookshelf from '@/components/app/LazyBookshelf.vue'
|
||||||
|
|
||||||
describe('LazyBookshelf', () => {
|
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 requestUrls = []
|
||||||
const getStub = cy.stub().callsFake((url) => {
|
const getStub = cy.stub().callsFake((url) => {
|
||||||
requestUrls.push(url)
|
requestUrls.push(url)
|
||||||
|
|
@ -25,84 +106,47 @@ describe('LazyBookshelf', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const store = {
|
mountBookshelf(getStub).then(({ wrapper }) => {
|
||||||
getters: {
|
cy.wrap(getStub).should('have.been.calledTwice').then(() => {
|
||||||
'user/getIsAdminOrUp': true,
|
expect(wrapper.vm.pagesLoaded[1]).to.be.ok
|
||||||
'libraries/getCurrentLibraryMediaType': 'book',
|
expect(wrapper.vm.pageCursors[1]).to.equal('cursor-1')
|
||||||
'libraries/getCurrentLibraryName': 'Library',
|
expect(wrapper.vm.paginationMode).to.equal('keyset')
|
||||||
'user/getUserSetting': (key) => {
|
expect(wrapper.vm.isProgressiveLoading).to.equal(false)
|
||||||
const settings = {
|
expect(requestUrls).to.have.length(2)
|
||||||
orderBy: 'media.metadata.title',
|
expect(requestUrls[0]).to.include('page=0')
|
||||||
orderDesc: false,
|
expect(requestUrls[0]).to.not.include('cursor=')
|
||||||
filterBy: 'all',
|
expect(requestUrls[1]).to.include('cursor=cursor-1')
|
||||||
collapseSeries: false,
|
expect(requestUrls[1]).to.not.include('page=1')
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
cy.mount(LazyBookshelf, {
|
it('clears progressive loading state when a keyset follow-up chunk fails', () => {
|
||||||
mocks: {
|
const getStub = cy.stub()
|
||||||
$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)
|
|
||||||
|
|
||||||
expect(requestUrls).to.have.length(2)
|
getStub.onFirstCall().resolves({
|
||||||
expect(requestUrls[0]).to.include('page=0')
|
results: [{ id: 'item-1', mediaType: 'book', media: { metadata: { title: 'Alpha' } } }],
|
||||||
expect(requestUrls[0]).to.not.include('cursor=')
|
total: 4,
|
||||||
expect(requestUrls[1]).to.include('cursor=cursor-1')
|
nextCursor: 'cursor-1',
|
||||||
expect(requestUrls[1]).to.not.include('page=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