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