mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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']);
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user