feat(client): add placeholder UX for series/upload flows, card styling/action guards, and localized strings

This commit is contained in:
Jens Bickel 2026-02-09 17:19:21 +01:00
parent 2a775d434a
commit fc325a4095
23 changed files with 850 additions and 65 deletions

View file

@ -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')
})
})

View file

@ -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 () => {

View file

@ -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)
})
})