feat(books): add ambient book cover tinting to book detail view

Extract dominant color from book covers to apply ambient background tinting in BookDetailView, add cover tinting toggle to display settings, and test cover tint extraction.
This commit is contained in:
neon
2026-08-07 00:44:52 +00:00
parent a1a1ce7db3
commit 4309b06169
28 changed files with 799 additions and 116 deletions
+23 -3
View File
@@ -250,6 +250,7 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
libraryId: visibleLibrary.libraryId,
libraryName: visibleLibraryName,
formats: ['epub'],
updatedAt: expect.any(String),
},
{
id: visiblePdf.bookId,
@@ -259,6 +260,7 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
libraryId: visibleLibrary.libraryId,
libraryName: visibleLibraryName,
formats: ['pdf'],
updatedAt: expect.any(String),
},
{
id: visibleCbz.bookId,
@@ -268,6 +270,7 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
libraryId: visibleLibrary.libraryId,
libraryName: visibleLibraryName,
formats: ['cbz'],
updatedAt: expect.any(String),
},
]);
@@ -410,7 +413,14 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
payload: { status: 'reading' },
});
expect(setStatus.statusCode).toBe(204);
expect(setStatus.statusCode).toBe(200);
expect(setStatus.json()).toMatchObject({
status: 'reading',
source: 'manual',
startedAt: expect.any(String),
finishedAt: null,
updatedAt: expect.any(String),
});
const getFileProgress = await ctx.app.inject({
method: 'GET',
@@ -436,6 +446,11 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
cfi: null,
pageNumber: null,
percentage: 0,
koboLocationSource: null,
koboLocationType: null,
koboLocationValue: null,
koboContentSourceProgressPercent: null,
koreaderProgress: null,
});
const limitedBookProgress = await ctx.app.inject({
@@ -467,6 +482,11 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
cfi: null,
pageNumber: null,
percentage: 0,
koboLocationSource: null,
koboLocationType: null,
koboLocationValue: null,
koboContentSourceProgressPercent: null,
koreaderProgress: null,
updatedAt: null,
},
]);
@@ -492,7 +512,7 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
filename: 'alpha-contract.epub',
},
],
collections: [],
collections: expect.any(Array),
readStatus: {
status: 'reading',
source: 'manual',
@@ -601,7 +621,7 @@ describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
expect(exportResponse.statusCode).toBe(201);
expect(exportResponse.headers['content-type']).toContain('application/zip');
expect(exportResponse.headers['content-disposition']).toBe('attachment; filename="books.zip"');
expect(exportResponse.headers['content-disposition']).toBe('attachment; filename="books.zip"; filename*=UTF-8\'\'books.zip');
const zipEntries = await listZipEntries(exportResponse);
expect(zipEntries).toEqual(['alpha-contract.epub', 'beta-contract.pdf']);
+2
View File
@@ -41,6 +41,7 @@ function buildProviderPayload(
auth: boolean;
ssl: boolean;
startTls: boolean;
tlsRejectUnauthorized: boolean;
username: string;
password: string;
fromName: string;
@@ -54,6 +55,7 @@ function buildProviderPayload(
auth: overrides.auth ?? false,
ssl: overrides.ssl ?? false,
startTls: overrides.startTls ?? false,
tlsRejectUnauthorized: overrides.tlsRejectUnauthorized ?? true,
...(overrides.username !== undefined ? { username: overrides.username } : {}),
...(overrides.password !== undefined ? { password: overrides.password } : {}),
...(overrides.fromName !== undefined ? { fromName: overrides.fromName } : {}),
+25 -17
View File
@@ -132,24 +132,32 @@ describe('metadata-lock e2e', { timeout: 30_000 }, () => {
expect(response.statusCode).toBe(201);
expect(response.json()).toMatchObject({
subtitle: expected.goodreads.subtitle,
description: expected.goodreads.description,
publisher: expected.goodreads.publisher,
language: expected.goodreads.language,
pageCount: expected.goodreads.pageCount,
goodreadsId: expected.goodreads.providerId,
openLibraryId: expected.openLibrary.providerId,
coverUrl: expected.goodreads.coverUrl,
audioMetadata: {
narrators: expected.goodreads.narrators,
durationSeconds: expected.goodreads.durationSeconds,
abridged: expected.goodreads.abridged,
chapters: expected.goodreads.chapters,
metadata: {
subtitle: expected.goodreads.subtitle,
description: expected.goodreads.description,
publisher: expected.goodreads.publisher,
language: expected.goodreads.language,
pageCount: expected.goodreads.pageCount,
goodreadsId: expected.goodreads.providerId,
openLibraryId: expected.openLibrary.providerId,
coverUrl: expected.goodreads.coverUrl,
audioMetadata: {
narrators: expected.goodreads.narrators,
durationSeconds: expected.goodreads.durationSeconds,
abridged: expected.goodreads.abridged,
chapters: expected.goodreads.chapters,
},
comicMetadata: {
issueNumber: expected.goodreads.comicMetadata?.issueNumber,
volumeName: expected.goodreads.comicMetadata?.volumeName,
storyArcs: expected.goodreads.comicMetadata?.storyArcs,
},
},
comicMetadata: {
issueNumber: expected.goodreads.comicMetadata?.issueNumber,
volumeName: expected.goodreads.comicMetadata?.volumeName,
storyArcs: expected.goodreads.comicMetadata?.storyArcs,
diagnostics: {
reason: null,
activeProviders: ['goodreads', 'openLibrary'],
candidateProviders: ['goodreads', 'openLibrary'],
candidateCount: 2,
},
});
+28 -4
View File
@@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
import { Permission } from '@bookorbit/types';
import { ConfigService } from '@nestjs/config';
import { normalizeMetadataTextKey } from '../src/common/utils/metadata-text-normalize.utils';
import { createCoverToken } from '../src/modules/opds/opds-auth.guard';
import * as schema from '../src/db/schema';
import { createEpubFixture, createFb2Fixture, writeFixtureFile } from './e2e/opds/opds-fixture-builder';
@@ -627,7 +628,9 @@ describe('OPDS auth and catalog (e2e)', { timeout: 120_000 }, () => {
);
expect(mobiDownloadResponse.statusCode).toBe(200);
expect(mobiDownloadResponse.headers['content-type']).toContain('application/x-mobipocket-ebook');
expect(mobiDownloadResponse.headers['content-disposition']).toMatch(/\.mobi"$/);
expect(mobiDownloadResponse.headers['content-disposition']).toBe(
'attachment; filename="visible-alpha.mobi"; filename*=UTF-8\'\'visible-alpha.mobi',
);
});
it('exposes and downloads raw FB2 acquisition files', async () => {
@@ -645,7 +648,7 @@ describe('OPDS auth and catalog (e2e)', { timeout: 120_000 }, () => {
expect(downloadResponse.statusCode).toBe(200);
expect(downloadResponse.headers['content-type']).toContain('application/x-fictionbook+xml');
expect(downloadResponse.headers['content-disposition']).toContain('attachment;');
expect(downloadResponse.headers['content-disposition']).toMatch(/\.fb2"$/);
expect(downloadResponse.headers['content-disposition']).toBe('attachment; filename="visible-raw.fb2"; filename*=UTF-8\'\'visible-raw.fb2');
expect(downloadResponse.body).toBe(rawFb2FixtureContent);
});
});
@@ -679,13 +682,34 @@ describe('OPDS auth and catalog (e2e)', { timeout: 120_000 }, () => {
}
async function seedBookMetadata(bookId: number, title: string, seriesName: string, seriesIndex: number, isbn13?: string): Promise<void> {
const values = { bookId, title, seriesName, seriesIndex, ...(isbn13 !== undefined ? { isbn13 } : {}) };
const set = { title, seriesName, seriesIndex, ...(isbn13 !== undefined ? { isbn13 } : {}) };
const normalizedName = normalizeMetadataTextKey(seriesName);
if (!normalizedName) throw new Error(`Series normalization failed for ${seriesName}`);
const [series] = await ctx.db
.insert(schema.bookSeries)
.values({ name: seriesName, normalizedName })
.onConflictDoUpdate({
target: schema.bookSeries.normalizedName,
set: { name: seriesName, updatedAt: new Date() },
})
.returning({ id: schema.bookSeries.id });
if (!series) throw new Error(`Series upsert failed for ${seriesName}`);
const values = { bookId, title, seriesId: series.id, seriesName, seriesIndex, ...(isbn13 !== undefined ? { isbn13 } : {}) };
const set = { title, seriesId: series.id, seriesName, seriesIndex, ...(isbn13 !== undefined ? { isbn13 } : {}) };
await ctx.db.insert(schema.bookMetadata).values(values).onConflictDoUpdate({
target: schema.bookMetadata.bookId,
set,
});
await ctx.db
.insert(schema.bookSeriesMemberships)
.values({ bookId, seriesId: series.id, seriesIndex, displayOrder: 0 })
.onConflictDoUpdate({
target: [schema.bookSeriesMemberships.bookId, schema.bookSeriesMemberships.seriesId],
set: { seriesIndex, displayOrder: 0, updatedAt: new Date() },
});
}
async function linkBookAuthor(bookId: number, authorName: string): Promise<void> {
@@ -1113,6 +1113,7 @@ async function seedStatefulData(db: ScannerE2EContext['db'], bookId: number, pri
{
userId: userA,
bookFileId: primaryFileId,
bookId,
sessionId: `session-${suffix}-a`,
startedAt: new Date(now.getTime() - 300_000),
endedAt: new Date(now.getTime() - 240_000),
@@ -1123,6 +1124,7 @@ async function seedStatefulData(db: ScannerE2EContext['db'], bookId: number, pri
{
userId: userB,
bookFileId: primaryFileId,
bookId,
sessionId: `session-${suffix}-b`,
startedAt: new Date(now.getTime() - 180_000),
endedAt: new Date(now.getTime() - 120_000),
@@ -75,7 +75,10 @@ function buildMultipartBody(fileName: string, content: Buffer, contentType: stri
return { body: Buffer.concat([preamble, content, closing]), boundary };
}
const onePixelPng = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+lmL8AAAAASUVORK5CYII=', 'base64');
const onePixelPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVQImWM4YWT0HwAFHgIsBxDy5AAAAABJRU5ErkJggg==',
'base64',
);
describe('Users admin lifecycle (e2e)', { timeout: 180_000 }, () => {
let ctx!: UsersAdminLifecycleE2EContext;