feat(settings): add option to turn book spine off for comics

Adds a display preference to conditionally render book spines on comic
formats. The new setting is defaulted to false. Various book cover
surfaces across the application now respect this preference by checking
if the format is a comic before rendering the spine.

Closes #295
This commit is contained in:
neon
2026-06-15 05:56:39 +00:00
parent f525c01a2f
commit 96e42d2ac1
35 changed files with 548 additions and 23 deletions
@@ -303,8 +303,8 @@ describe('BookRepository', () => {
const result = await repo.findRecommendationTitlesByBookIds([10, 11]);
expect(result).toEqual([
{ id: 10, title: 'Dune', hasCover: true, authors: ['Frank Herbert'], isAudiobook: true },
{ id: 11, title: 'Foundation', hasCover: false, authors: ['Isaac Asimov', 'Robert Heinlein'], isAudiobook: false },
{ id: 10, title: 'Dune', hasCover: true, authors: ['Frank Herbert'], isAudiobook: true, isComic: false },
{ id: 11, title: 'Foundation', hasCover: false, authors: ['Isaac Asimov', 'Robert Heinlein'], isAudiobook: false, isComic: false },
]);
});
@@ -317,7 +317,7 @@ describe('BookRepository', () => {
const result = await repo.findRecommendationTitlesByBookIds([5]);
expect(result).toEqual([{ id: 5, title: 'No Cover', hasCover: false, authors: [], isAudiobook: false }]);
expect(result).toEqual([{ id: 5, title: 'No Cover', hasCover: false, authors: [], isAudiobook: false, isComic: false }]);
});
it('treats primary format checks as case-insensitive in recommendation rows', async () => {
@@ -329,7 +329,19 @@ describe('BookRepository', () => {
const result = await repo.findRecommendationTitlesByBookIds([6]);
expect(result).toEqual([{ id: 6, title: 'Audio Case', hasCover: true, authors: [], isAudiobook: true }]);
expect(result).toEqual([{ id: 6, title: 'Audio Case', hasCover: true, authors: [], isAudiobook: true, isComic: false }]);
});
it('flags comic primary formats as isComic in recommendation rows', async () => {
const bookRows = [{ id: 8, title: 'Comic Case', coverSource: 'custom', primaryFormat: 'CBR' }];
const db = {
select: vi.fn().mockReturnValueOnce(makeSelectChain('where', bookRows)).mockReturnValueOnce(makeSelectChain('where', [])),
};
const repo = new BookRepository(db as never);
const result = await repo.findRecommendationTitlesByBookIds([8]);
expect(result).toEqual([{ id: 8, title: 'Comic Case', hasCover: true, authors: [], isAudiobook: false, isComic: true }]);
});
it('maps id-list helper rows and primary-file lookups', async () => {
@@ -360,7 +372,7 @@ describe('BookRepository', () => {
await expect(repo.findLibraryIdsByBookIds([1])).resolves.toEqual(libraryRows);
await expect(repo.findRecommendationTitlesByBookIds([1])).resolves.toEqual([
{ id: 1, title: 'Dune', hasCover: true, authors: ['Frank Herbert'], isAudiobook: true },
{ id: 1, title: 'Dune', hasCover: true, authors: ['Frank Herbert'], isAudiobook: true, isComic: false },
]);
await expect(repo.findPrimaryFilesByBookIds([1])).resolves.toEqual(primaryFilesByIds);
await expect(repo.findAllFilesByBookIds([1])).resolves.toEqual(allFilesByIds);
+3 -2
View File
@@ -4,7 +4,7 @@ import { SUPPORTED_BOOK_FORMATS } from '../upload/upload-validator.service';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { ContentFilterRules, JumpBucketsResponse, SortSpec } from '@bookorbit/types';
import { isAudioFormat } from '@bookorbit/types';
import { isAudioFormat, isComicFormat } from '@bookorbit/types';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
import { SeriesIdentityService } from '../../common/services/series-identity.service';
import { BookQueryBuilder } from './book-query-builder.service';
@@ -949,7 +949,7 @@ export class BookRepository {
async findRecommendationTitlesByBookIds(
bookIds: number[],
): Promise<{ id: number; title: string | null; hasCover: boolean; authors: string[]; isAudiobook: boolean }[]> {
): Promise<{ id: number; title: string | null; hasCover: boolean; authors: string[]; isAudiobook: boolean; isComic: boolean }[]> {
if (bookIds.length === 0) return [];
const [rows, authorRows] = await Promise.all([
@@ -979,6 +979,7 @@ export class BookRepository {
hasCover: r.coverSource !== null,
authors: authorsByBook.get(r.id) ?? [],
isAudiobook: r.primaryFormat != null ? isAudioFormat(r.primaryFormat) : false,
isComic: r.primaryFormat != null ? isComicFormat(r.primaryFormat) : false,
}));
}
@@ -243,8 +243,8 @@ describe('RecommendationRepository', () => {
const result = await repo.findSeriesBooks(88, [3, 4]);
expect(result).toEqual([
{ bookId: 1, title: 'Book 1', seriesIndex: 1, coverSource: 'extracted', authorNames: ['Frank Herbert'], isAudiobook: true },
{ bookId: 2, title: 'Book 2', seriesIndex: 2, coverSource: null, authorNames: [], isAudiobook: false },
{ bookId: 1, title: 'Book 1', seriesIndex: 1, coverSource: 'extracted', authorNames: ['Frank Herbert'], isAudiobook: true, isComic: false },
{ bookId: 2, title: 'Book 2', seriesIndex: 2, coverSource: null, authorNames: [], isAudiobook: false, isComic: false },
]);
expect(select).toHaveBeenCalledTimes(2);
expect(chains[0].from).toHaveBeenCalledTimes(1);
@@ -264,7 +264,9 @@ describe('RecommendationRepository', () => {
const result = await repo.findSeriesBooks(88, [1]);
expect(result).toEqual([{ bookId: 5, title: 'Solo Book', seriesIndex: null, coverSource: null, authorNames: [], isAudiobook: false }]);
expect(result).toEqual([
{ bookId: 5, title: 'Solo Book', seriesIndex: null, coverSource: null, authorNames: [], isAudiobook: false, isComic: false },
]);
});
it('returns empty author books when libraryIds is empty', async () => {
@@ -289,7 +291,9 @@ describe('RecommendationRepository', () => {
const result = await repo.findAuthorBooks(1, [3]);
expect(result).toEqual([{ bookId: 10, title: 'Other Book', coverSource: 'extracted', authorNames: ['Terry Pratchett'], isAudiobook: true }]);
expect(result).toEqual([
{ bookId: 10, title: 'Other Book', coverSource: 'extracted', authorNames: ['Terry Pratchett'], isAudiobook: true, isComic: false },
]);
expect(select).toHaveBeenCalledTimes(3);
expect(chains[1].innerJoin).toHaveBeenCalledTimes(1);
expect(chains[1].leftJoin).toHaveBeenCalledTimes(2);
@@ -307,6 +311,21 @@ describe('RecommendationRepository', () => {
const result = await repo.findAuthorBooks(1, [1]);
expect(result).toEqual([{ bookId: 7, title: 'Anonymous Work', coverSource: null, authorNames: [], isAudiobook: false }]);
expect(result).toEqual([{ bookId: 7, title: 'Anonymous Work', coverSource: null, authorNames: [], isAudiobook: false, isComic: false }]);
});
it('flags comic primary formats as isComic for series books', async () => {
const rows = [{ bookId: 9, title: 'Comic Issue', seriesIndex: 3, coverSource: 'extracted', primaryFormat: 'CBZ' }];
const { db } = makeDb([
{ terminal: 'limit', result: rows },
{ terminal: 'where', result: [] },
]);
const repo = new RecommendationRepository(db);
const result = await repo.findSeriesBooks(88, [1]);
expect(result).toEqual([
{ bookId: 9, title: 'Comic Issue', seriesIndex: 3, coverSource: 'extracted', authorNames: [], isAudiobook: false, isComic: true },
]);
});
});
@@ -3,7 +3,7 @@ import { eq, inArray, ne, and, isNotNull, sql, asc, desc } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { ContentFilterRules } from '@bookorbit/types';
import { isAudioFormat } from '@bookorbit/types';
import { isAudioFormat, isComicFormat } from '@bookorbit/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookGenres, bookMetadata, bookTags, books, genres, tags } from '../../db/schema';
@@ -21,6 +21,7 @@ export interface SeriesBookRow {
coverSource: string | null;
authorNames: string[];
isAudiobook: boolean;
isComic: boolean;
}
export interface AuthorBookRow {
@@ -29,6 +30,7 @@ export interface AuthorBookRow {
coverSource: string | null;
authorNames: string[];
isAudiobook: boolean;
isComic: boolean;
}
export interface AnnCandidate {
@@ -196,6 +198,7 @@ export class RecommendationRepository {
coverSource: r.coverSource,
authorNames: authorsByBook.get(r.bookId) ?? [],
isAudiobook: r.primaryFormat != null ? isAudioFormat(r.primaryFormat) : false,
isComic: r.primaryFormat != null ? isComicFormat(r.primaryFormat) : false,
}));
}
@@ -240,6 +243,7 @@ export class RecommendationRepository {
coverSource: r.coverSource,
authorNames: authorsByBook.get(r.bookId) ?? [],
isAudiobook: r.primaryFormat != null ? isAudioFormat(r.primaryFormat) : false,
isComic: r.primaryFormat != null ? isComicFormat(r.primaryFormat) : false,
}));
}
@@ -92,7 +92,10 @@ export class RecommendationService {
const rowMap = new Map(rows.map((row) => [row.id, row]));
const recommendations = rescored
.map((rescoredCandidate) => rowMap.get(rescoredCandidate.bookId))
.filter((row): row is { id: number; title: string | null; hasCover: boolean; authors: string[]; isAudiobook: boolean } => row != null);
.filter(
(row): row is { id: number; title: string | null; hasCover: boolean; authors: string[]; isAudiobook: boolean; isComic: boolean } =>
row != null,
);
this.logger.log(
`[${RECOMMENDATION_EVENT}] [end] bookId=${bookId} userId=${user.id} libraryId=${libraryId} durationMs=${Date.now() - startedAt} accessibleLibraryCount=${accessibleLibraryIds.length} candidateCount=${candidates.length} rescoredCount=${rescored.length} resultCount=${recommendations.length} - recommendation lookup completed`,
@@ -139,6 +142,7 @@ export class RecommendationService {
hasCover: r.coverSource !== null,
authors: r.authorNames,
isAudiobook: r.isAudiobook,
isComic: r.isComic,
}));
} catch (err) {
const { errorClass, errorMessage } = this.parseError(err);
@@ -171,6 +175,7 @@ export class RecommendationService {
hasCover: r.coverSource !== null,
authors: r.authorNames,
isAudiobook: r.isAudiobook,
isComic: r.isComic,
}));
} catch (err) {
const { errorClass, errorMessage } = this.parseError(err);
@@ -28,6 +28,7 @@ const validDisplayPreferences: DisplayPreferences = {
tableZebraStriping: false,
tableDensity: 'comfortable',
bookSpineOverlay: 'subtle',
showSpineOnComics: false,
bookShadowStrength: 'strong',
bookCoverDisplayMode: 'natural-bottom',
seriesCardCoverMode: 'stack',
@@ -195,6 +196,31 @@ describe('UserPreferencesService', () => {
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ seriesCardCoverMode: 'stack' }));
});
it('upsertDisplayPreferences accepts both showSpineOnComics values', async () => {
for (const value of [true, false]) {
await expect(
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, showSpineOnComics: value } as unknown as Record<string, unknown>),
).resolves.toBeUndefined();
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ showSpineOnComics: value }));
}
});
it('upsertDisplayPreferences defaults showSpineOnComics to false when omitted', async () => {
const { showSpineOnComics, ...withoutFlag } = validDisplayPreferences;
void showSpineOnComics;
await expect(service.upsertDisplayPreferences(11, withoutFlag as unknown as Record<string, unknown>)).resolves.toBeUndefined();
expect(repo.upsert).toHaveBeenCalledWith(11, 'display', expect.objectContaining({ showSpineOnComics: false }));
});
it('upsertDisplayPreferences rejects non-boolean showSpineOnComics', async () => {
await expect(
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, showSpineOnComics: 'yes' } as unknown as Record<string, unknown>),
).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertDisplayPreferences rejects invalid cover display modes', async () => {
await expect(
service.upsertDisplayPreferences(11, { ...validDisplayPreferences, bookCoverDisplayMode: 'stretched' } as never),
@@ -50,6 +50,7 @@ const DISPLAY_PREFERENCES_SCHEMA = z
tableZebraStriping: z.boolean(),
tableDensity: z.enum(TABLE_DENSITIES),
bookSpineOverlay: z.enum(BOOK_SPINE_OVERLAYS),
showSpineOnComics: z.boolean().default(false),
bookShadowStrength: z.enum(BOOK_SHADOW_STRENGTHS),
bookCoverDisplayMode: z.enum(BOOK_COVER_DISPLAY_MODES),
seriesCardCoverMode: z.enum(SERIES_CARD_COVER_MODES).default('stack'),