mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 09:51:37 +00:00
feat(client): add placeholder UX for series/upload flows, card styling/action guards, and localized strings
This commit is contained in:
parent
2a775d434a
commit
fc325a4095
23 changed files with 850 additions and 65 deletions
|
|
@ -9,6 +9,7 @@ const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
|
|||
const Auth = require('../../../server/Auth')
|
||||
const Logger = require('../../../server/Logger')
|
||||
const User = require('../../../server/models/User')
|
||||
const fs = require('../../../server/libs/fsExtra')
|
||||
|
||||
describe('SeriesController placeholders', () => {
|
||||
/** @type {ApiRouter} */
|
||||
|
|
@ -31,6 +32,7 @@ describe('SeriesController placeholders', () => {
|
|||
})
|
||||
|
||||
sinon.stub(Logger, 'warn')
|
||||
sinon.stub(fs, 'ensureDir').resolves()
|
||||
|
||||
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||
libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id })
|
||||
|
|
@ -78,6 +80,71 @@ describe('SeriesController placeholders', () => {
|
|||
expect(payload.media.metadata.series.some((entry) => entry.id === series.id)).to.be.true
|
||||
})
|
||||
|
||||
it('creates placeholder directories on disk', async () => {
|
||||
const fakeReq = {
|
||||
params: {
|
||||
id: library.id,
|
||||
seriesId: series.id
|
||||
},
|
||||
user,
|
||||
body: {}
|
||||
}
|
||||
|
||||
const fakeRes = {
|
||||
status: sinon.stub().returnsThis(),
|
||||
json: sinon.spy(),
|
||||
sendStatus: sinon.spy(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
|
||||
await SeriesController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
|
||||
|
||||
expect(fs.ensureDir.calledOnce).to.be.true
|
||||
expect(fs.ensureDir.firstCall.args[0]).to.equal('/test/Test Series/Placeholder')
|
||||
})
|
||||
|
||||
it('includes the author folder when creating placeholders for a series item with authors', async () => {
|
||||
const existingBook = await Database.bookModel.create({
|
||||
title: 'Existing Book',
|
||||
audioFiles: [],
|
||||
tags: [],
|
||||
narrators: [],
|
||||
genres: [],
|
||||
chapters: []
|
||||
})
|
||||
await Database.bookSeriesModel.create({ bookId: existingBook.id, seriesId: series.id })
|
||||
await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: existingBook.id,
|
||||
mediaType: 'book',
|
||||
libraryId: library.id,
|
||||
libraryFolderId: libraryFolder.id,
|
||||
authorNamesFirstLast: 'Jane Doe',
|
||||
authorNamesLastFirst: 'Doe, Jane'
|
||||
})
|
||||
|
||||
const fakeReq = {
|
||||
params: {
|
||||
id: library.id,
|
||||
seriesId: series.id
|
||||
},
|
||||
user,
|
||||
body: {}
|
||||
}
|
||||
|
||||
const fakeRes = {
|
||||
status: sinon.stub().returnsThis(),
|
||||
json: sinon.spy(),
|
||||
sendStatus: sinon.spy(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
|
||||
await SeriesController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
|
||||
|
||||
expect(fs.ensureDir.calledOnce).to.be.true
|
||||
expect(fs.ensureDir.firstCall.args[0]).to.equal('/test/Jane Doe/Test Series/Placeholder')
|
||||
})
|
||||
|
||||
it('rejects cover url payloads for placeholder creation', async () => {
|
||||
const fakeReq = {
|
||||
params: {
|
||||
|
|
@ -216,4 +283,30 @@ describe('SeriesController placeholders', () => {
|
|||
expect(payload.folderId).to.equal(libraryFolder.id)
|
||||
expect(payload.path.startsWith(libraryFolder.path)).to.be.true
|
||||
})
|
||||
|
||||
it('falls back to the first library folder when the series has no items', async () => {
|
||||
const fakeReq = {
|
||||
params: {
|
||||
id: library.id,
|
||||
seriesId: series.id
|
||||
},
|
||||
user,
|
||||
body: {}
|
||||
}
|
||||
|
||||
const fakeRes = {
|
||||
status: sinon.stub().returnsThis(),
|
||||
json: sinon.spy(),
|
||||
sendStatus: sinon.spy(),
|
||||
send: sinon.spy()
|
||||
}
|
||||
|
||||
await SeriesController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
|
||||
|
||||
expect(fakeRes.json.calledOnce).to.be.true
|
||||
const payload = fakeRes.json.firstCall.args[0]
|
||||
expect(payload.folderId).to.equal(libraryFolder.id)
|
||||
expect(payload.path.startsWith(libraryFolder.path)).to.be.true
|
||||
expect(payload.path).to.include('/Test Series/Placeholder')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ describe('LibraryScanner placeholder handling', () => {
|
|||
path: '/library/Series/Placeholder',
|
||||
relPath: 'Series/Placeholder',
|
||||
isPlaceholder: true,
|
||||
isMissing: false,
|
||||
isMissing: true,
|
||||
media: { title: 'Placeholder Book' },
|
||||
save: sinon.stub().resolves(),
|
||||
changed: sinon.stub()
|
||||
|
|
@ -118,6 +118,7 @@ describe('LibraryScanner placeholder handling', () => {
|
|||
const results = await LibraryScanner.scanFolderUpdates(library, folder, fileUpdateGroup)
|
||||
|
||||
expect(placeholderItem.isPlaceholder).to.be.false
|
||||
expect(placeholderItem.isMissing).to.be.false
|
||||
expect(placeholderItem.save.calledOnce).to.be.true
|
||||
expect(scanLibraryItemStub.calledOnce).to.be.true
|
||||
expect(results['Series/Placeholder']).to.equal(ScanResult.UPDATED)
|
||||
|
|
@ -145,7 +146,7 @@ describe('LibraryScanner placeholder handling', () => {
|
|||
path: '/library/Series/Placeholder',
|
||||
relPath: 'Series/Placeholder',
|
||||
isPlaceholder: true,
|
||||
isMissing: false,
|
||||
isMissing: true,
|
||||
save: sinon.stub().resolves(),
|
||||
changed: sinon.stub()
|
||||
}
|
||||
|
|
@ -162,14 +163,16 @@ describe('LibraryScanner placeholder handling', () => {
|
|||
sinon.stub(libraryFilters, 'getFilterData').resolves()
|
||||
sinon.stub(LibraryScanner, 'scanFolder').resolves([libraryItemData])
|
||||
sinon.stub(Database.libraryItemModel, 'findAll').resolves([placeholderItem])
|
||||
sinon.stub(LibraryItemScanner, 'rescanLibraryItemMedia').resolves({ libraryItem: placeholderItem, wasUpdated: true })
|
||||
const rescanStub = sinon.stub(LibraryItemScanner, 'rescanLibraryItemMedia').resolves({ libraryItem: placeholderItem, wasUpdated: true })
|
||||
sinon.stub(LibraryItemScanner, 'checkAuthorsAndSeriesRemovedFromBooks').resolves()
|
||||
sinon.stub(SocketAuthority, 'libraryItemsEmitter')
|
||||
|
||||
await LibraryScanner.scanLibrary(libraryScan, false)
|
||||
|
||||
expect(placeholderItem.isPlaceholder).to.be.false
|
||||
expect(placeholderItem.isMissing).to.be.false
|
||||
expect(placeholderItem.save.calledOnce).to.be.true
|
||||
expect(rescanStub.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
it('skips placeholder scans on file updates without audio', async () => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ describe('libraryItemsBookFilters placeholders', () => {
|
|||
beforeEach(async () => {
|
||||
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||
global.ServerSettings = { sortingIgnorePrefix: false }
|
||||
await Database.buildModels()
|
||||
|
||||
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||
|
|
@ -54,11 +55,17 @@ describe('libraryItemsBookFilters placeholders', () => {
|
|||
return { book, libraryItem }
|
||||
}
|
||||
|
||||
const createSeriesWithBook = async ({ seriesName, title, isPlaceholder }) => {
|
||||
const series = await Database.seriesModel.create({
|
||||
name: seriesName,
|
||||
libraryId: library.id
|
||||
})
|
||||
const createSeriesWithBook = async ({ seriesName, seriesId, title, isPlaceholder }) => {
|
||||
let series = null
|
||||
if (seriesId) {
|
||||
series = await Database.seriesModel.findByPk(seriesId)
|
||||
}
|
||||
if (!series) {
|
||||
series = await Database.seriesModel.create({
|
||||
name: seriesName,
|
||||
libraryId: library.id
|
||||
})
|
||||
}
|
||||
const { book, libraryItem } = await createBookWithItem({ title, isPlaceholder })
|
||||
await Database.bookSeriesModel.create({
|
||||
seriesId: series.id,
|
||||
|
|
@ -68,11 +75,17 @@ describe('libraryItemsBookFilters placeholders', () => {
|
|||
return { series, book, libraryItem }
|
||||
}
|
||||
|
||||
const createAuthorWithBook = async ({ authorName, title, isPlaceholder }) => {
|
||||
const author = await Database.authorModel.create({
|
||||
name: authorName,
|
||||
libraryId: library.id
|
||||
})
|
||||
const createAuthorWithBook = async ({ authorName, authorId, title, isPlaceholder }) => {
|
||||
let author = null
|
||||
if (authorId) {
|
||||
author = await Database.authorModel.findByPk(authorId)
|
||||
}
|
||||
if (!author) {
|
||||
author = await Database.authorModel.create({
|
||||
name: authorName,
|
||||
libraryId: library.id
|
||||
})
|
||||
}
|
||||
const { book, libraryItem } = await createBookWithItem({ title, isPlaceholder })
|
||||
await Database.bookAuthorModel.create({
|
||||
authorId: author.id,
|
||||
|
|
@ -85,18 +98,7 @@ describe('libraryItemsBookFilters placeholders', () => {
|
|||
await createBookWithItem({ title: 'Real Book', isPlaceholder: false })
|
||||
await createBookWithItem({ title: 'Placeholder Book', isPlaceholder: true })
|
||||
|
||||
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(
|
||||
library.id,
|
||||
user,
|
||||
null,
|
||||
null,
|
||||
'addedAt',
|
||||
true,
|
||||
false,
|
||||
[],
|
||||
20,
|
||||
0
|
||||
)
|
||||
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, null, null, 'addedAt', true, false, [], 20, 0)
|
||||
|
||||
expect(count).to.equal(1)
|
||||
expect(libraryItems).to.have.length(1)
|
||||
|
|
@ -115,7 +117,7 @@ describe('libraryItemsBookFilters placeholders', () => {
|
|||
it('includes placeholders when fetching items for a series', async () => {
|
||||
const real = await createSeriesWithBook({ seriesName: 'Series A', title: 'Real Book', isPlaceholder: false })
|
||||
await createBookWithItem({ title: 'Standalone Book', isPlaceholder: false })
|
||||
await createSeriesWithBook({ seriesName: 'Series A', title: 'Placeholder Book', isPlaceholder: true })
|
||||
await createSeriesWithBook({ seriesId: real.series.id, title: 'Placeholder Book', isPlaceholder: true })
|
||||
|
||||
const items = await libraryItemsBookFilters.getLibraryItemsForSeries(real.series, user)
|
||||
|
||||
|
|
@ -127,7 +129,7 @@ describe('libraryItemsBookFilters placeholders', () => {
|
|||
it('includes placeholders when fetching items for an author', async () => {
|
||||
const real = await createAuthorWithBook({ authorName: 'Author A', title: 'Real Book', isPlaceholder: false })
|
||||
await createBookWithItem({ title: 'Standalone Book', isPlaceholder: false })
|
||||
await createAuthorWithBook({ authorName: 'Author A', title: 'Placeholder Book', isPlaceholder: true })
|
||||
await createAuthorWithBook({ authorId: real.author.id, title: 'Placeholder Book', isPlaceholder: true })
|
||||
|
||||
const { libraryItems, count } = await libraryFilters.getLibraryItemsForAuthor(real.author, user, 20, 0)
|
||||
|
||||
|
|
@ -135,4 +137,58 @@ describe('libraryItemsBookFilters placeholders', () => {
|
|||
const titles = libraryItems.map((item) => item.media.title).sort()
|
||||
expect(titles).to.deep.equal(['Placeholder Book', 'Real Book'])
|
||||
})
|
||||
|
||||
it('includes placeholders only when includePlaceholders option is enabled', async () => {
|
||||
await createBookWithItem({ title: 'Placeholder Book', isPlaceholder: true })
|
||||
|
||||
const excluded = await libraryFilters.getFilteredLibraryItems(library.id, user, {
|
||||
filterBy: null,
|
||||
sortBy: 'addedAt',
|
||||
sortDesc: true,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
collapseseries: false,
|
||||
include: [],
|
||||
mediaType: 'book',
|
||||
includePlaceholders: false
|
||||
})
|
||||
|
||||
const included = await libraryFilters.getFilteredLibraryItems(library.id, user, {
|
||||
filterBy: null,
|
||||
sortBy: 'addedAt',
|
||||
sortDesc: true,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
collapseseries: false,
|
||||
include: [],
|
||||
mediaType: 'book',
|
||||
includePlaceholders: true
|
||||
})
|
||||
|
||||
expect(excluded.count).to.equal(0)
|
||||
expect(excluded.libraryItems).to.have.length(0)
|
||||
expect(included.count).to.equal(1)
|
||||
expect(included.libraryItems).to.have.length(1)
|
||||
expect(included.libraryItems[0].media.title).to.equal('Placeholder Book')
|
||||
})
|
||||
|
||||
it('excludes placeholders from library stats totals', async () => {
|
||||
await createBookWithItem({ title: 'Real Book', isPlaceholder: false })
|
||||
await createBookWithItem({ title: 'Placeholder Book', isPlaceholder: true })
|
||||
|
||||
const stats = await libraryItemsBookFilters.getBookLibraryStats(library.id)
|
||||
|
||||
expect(stats.totalItems).to.equal(1)
|
||||
})
|
||||
|
||||
it('uses only real books in collapsed series numBooks', async () => {
|
||||
const real = await createSeriesWithBook({ seriesName: 'Series A', title: 'Real Book', isPlaceholder: false })
|
||||
await createSeriesWithBook({ seriesId: real.series.id, title: 'Placeholder Book', isPlaceholder: true })
|
||||
|
||||
const { libraryItems } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, null, null, 'media.metadata.title', false, true, [], 20, 0)
|
||||
|
||||
expect(libraryItems).to.have.length(1)
|
||||
expect(libraryItems[0].collapsedSeries).to.exist
|
||||
expect(libraryItems[0].collapsedSeries.numBooks).to.equal(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue