fix: prevent stale read status when navigating between similar books

This commit is contained in:
neon
2026-05-13 10:25:31 -06:00
parent 76ac9d3dc1
commit 7f9a4717a5
7 changed files with 630 additions and 33 deletions
@@ -0,0 +1,165 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>(),
}))
vi.mock('@/lib/api', () => ({
api: mocks.api,
}))
import { useBookDetail } from '../useBookDetail'
function makeResponse(data: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => data,
} as Response
}
function deferredResponse() {
let resolve!: (response: Response) => void
let reject!: (error?: unknown) => void
const promise = new Promise<Response>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('useBookDetail', () => {
beforeEach(() => {
mocks.api.mockReset()
})
it('loads a book detail successfully', async () => {
mocks.api.mockResolvedValue(makeResponse({ id: 20, title: 'Dune', readStatus: { status: 'reading' } }))
const detailState = useBookDetail()
await detailState.fetch(20)
expect(mocks.api).toHaveBeenCalledWith('/api/v1/books/20')
expect(detailState.detail.value).toEqual({ id: 20, title: 'Dune', readStatus: { status: 'reading' } })
expect(detailState.loading.value).toBe(false)
expect(detailState.error.value).toBeNull()
expect(detailState.notFound.value).toBe(false)
})
it('clears stale detail immediately while a new request is loading', async () => {
const pending = deferredResponse()
mocks.api.mockReturnValue(pending.promise)
const detailState = useBookDetail()
detailState.detail.value = { id: 19 } as never
const fetchPromise = detailState.fetch(20)
expect(detailState.detail.value).toBeNull()
expect(detailState.loading.value).toBe(true)
expect(detailState.notFound.value).toBe(false)
expect(detailState.error.value).toBeNull()
pending.resolve(makeResponse({ id: 20, title: 'Hyperion', readStatus: null }))
await fetchPromise
expect(detailState.detail.value).toEqual({ id: 20, title: 'Hyperion', readStatus: null })
expect(detailState.loading.value).toBe(false)
})
it('keeps the current detail visible while reloading the same book', async () => {
const pending = deferredResponse()
mocks.api.mockReturnValue(pending.promise)
const detailState = useBookDetail()
detailState.detail.value = { id: 20, title: 'Current Book', readStatus: { status: 'reading' } } as never
const fetchPromise = detailState.fetch(20)
expect(detailState.detail.value).toEqual({ id: 20, title: 'Current Book', readStatus: { status: 'reading' } })
expect(detailState.loading.value).toBe(true)
pending.resolve(makeResponse({ id: 20, title: 'Current Book', readStatus: { status: 'finished' } }))
await fetchPromise
expect(detailState.detail.value).toEqual({ id: 20, title: 'Current Book', readStatus: { status: 'finished' } })
expect(detailState.loading.value).toBe(false)
})
it('marks notFound for a 404 response', async () => {
mocks.api.mockResolvedValue(makeResponse({ message: 'missing' }, 404))
const detailState = useBookDetail()
detailState.detail.value = { id: 19 } as never
await detailState.fetch(999)
expect(detailState.detail.value).toBeNull()
expect(detailState.notFound.value).toBe(true)
expect(detailState.error.value).toBeNull()
expect(detailState.loading.value).toBe(false)
})
it('surfaces HTTP failures', async () => {
mocks.api.mockResolvedValue(makeResponse({ message: 'boom' }, 500))
const detailState = useBookDetail()
await detailState.fetch(20)
expect(detailState.detail.value).toBeNull()
expect(detailState.notFound.value).toBe(false)
expect(detailState.error.value).toBe('HTTP 500')
expect(detailState.loading.value).toBe(false)
})
it('surfaces thrown network errors', async () => {
mocks.api.mockRejectedValue(new Error('network down'))
const detailState = useBookDetail()
await detailState.fetch(20)
expect(detailState.detail.value).toBeNull()
expect(detailState.error.value).toBe('network down')
expect(detailState.notFound.value).toBe(false)
expect(detailState.loading.value).toBe(false)
})
it('ignores stale earlier responses after a newer fetch starts', async () => {
const first = deferredResponse()
const second = deferredResponse()
mocks.api.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const detailState = useBookDetail()
const firstFetch = detailState.fetch(20)
const secondFetch = detailState.fetch(21)
second.resolve(makeResponse({ id: 21, title: 'New Book', readStatus: null }))
await secondFetch
first.resolve(makeResponse({ id: 20, title: 'Old Book', readStatus: { status: 'reading' } }))
await firstFetch
expect(detailState.detail.value).toEqual({ id: 21, title: 'New Book', readStatus: null })
expect(detailState.error.value).toBeNull()
expect(detailState.notFound.value).toBe(false)
expect(detailState.loading.value).toBe(false)
})
it('ignores stale earlier failures after a newer fetch succeeds', async () => {
const first = deferredResponse()
const second = deferredResponse()
mocks.api.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const detailState = useBookDetail()
const firstFetch = detailState.fetch(20)
const secondFetch = detailState.fetch(21)
second.resolve(makeResponse({ id: 21, title: 'New Book', readStatus: null }))
await secondFetch
first.reject(new Error('stale failure'))
await firstFetch
expect(detailState.detail.value).toEqual({ id: 21, title: 'New Book', readStatus: null })
expect(detailState.error.value).toBeNull()
expect(detailState.loading.value).toBe(false)
})
})
@@ -7,22 +7,36 @@ export function useBookDetail() {
const loading = ref(false)
const error = ref<string | null>(null)
const notFound = ref(false)
let activeRequestId = 0
async function fetch(bookId: number) {
const requestId = ++activeRequestId
const shouldClearDetail = detail.value?.id !== bookId
loading.value = true
error.value = null
notFound.value = false
if (shouldClearDetail) detail.value = null
try {
const res = await api(`/api/v1/books/${bookId}`)
if (res.status === 404) {
if (requestId === activeRequestId && res.status === 404) {
notFound.value = true
return
}
if (!res.ok) throw new Error(`HTTP ${res.status}`)
detail.value = await res.json()
if (requestId === activeRequestId && res.status !== 404) {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const nextDetail = (await res.json()) as BookDetail
if (requestId === activeRequestId) {
detail.value = nextDetail
}
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load book'
} finally {
if (requestId === activeRequestId) {
error.value = e instanceof Error ? e.message : 'Failed to load book'
}
}
if (requestId === activeRequestId) {
loading.value = false
}
}
@@ -8,7 +8,8 @@ const mockState = vi.hoisted(() => ({
route: null as unknown as { params: { bookId: string }; query: Record<string, unknown> },
detail: null as unknown as { value: BookDetail | null },
loading: null as unknown as { value: boolean },
fetch: vi.fn<() => void>(),
notFound: null as unknown as { value: boolean },
fetch: vi.fn<(bookId: number) => void>(),
}))
vi.mock('vue-router', async (importOriginal) => {
@@ -23,6 +24,7 @@ vi.mock('@/features/book/composables/useBookDetail', () => ({
useBookDetail: () => ({
detail: mockState.detail,
loading: mockState.loading,
notFound: mockState.notFound,
fetch: mockState.fetch,
}),
}))
@@ -131,6 +133,7 @@ describe('BookDetailView', () => {
})
mockState.detail = ref(makeBook())
mockState.loading = ref(false)
mockState.notFound = ref(false)
mockState.fetch.mockReset()
})
@@ -160,4 +163,44 @@ describe('BookDetailView', () => {
await nextTick()
expect(wrapper.get('[data-test="details-rating"]').text()).toBe('4')
})
it('clears the old detail view while a new route fetch is in flight', async () => {
mockState.fetch.mockImplementation((bookId: number) => {
if (bookId !== 218) return
mockState.detail.value = null
mockState.loading.value = true
setTimeout(() => {
mockState.detail.value = makeBook({ id: 218, rating: null, readStatus: null, title: 'Next Book' })
mockState.loading.value = false
}, 0)
})
const wrapper = mount(BookDetailView, {
global: {
stubs: {
BookDetailLayout: { template: '<div><slot /></div>' },
DetailsTab: DetailsTabStub,
EditMetadataTab: EditMetadataTabStub,
FilesTab: { template: '<div />' },
EntityNotFound: { template: '<div data-test="not-found" />' },
},
},
})
expect(wrapper.get('[data-test="details-rating"]').text()).toBe('2')
mockState.route.params.bookId = '218'
await nextTick()
expect(wrapper.find('[data-test="details-rating"]').exists()).toBe(false)
expect(wrapper.find('.animate-shimmer').exists()).toBe(true)
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick()
expect(mockState.fetch).toHaveBeenCalledWith(218)
expect(wrapper.get('[data-test="details-rating"]').text()).toBe('')
})
})
+189 -1
View File
@@ -1951,6 +1951,26 @@ describe('BookService', () => {
expect(onProgress).toHaveBeenCalledTimes(2);
});
it('bulkRefreshMetadata returns zero counts without checking access when no books are requested', async () => {
const { service, bookRepo } = makeService();
await expect(service.bulkRefreshMetadata([], makeUser())).resolves.toEqual({ processed: 0, failed: 0 });
expect(bookRepo.findLibraryIdsByBookIds).not.toHaveBeenCalled();
});
it('bulkRefreshMetadata logs and rethrows access failures before iteration starts', async () => {
const { service } = makeService();
const warnSpy = vi.spyOn((service as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation();
const user = makeUser();
const accessError = new ForbiddenException('denied');
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockRejectedValue(accessError);
await expect(service.bulkRefreshMetadata([1], user)).rejects.toThrow(accessError);
expect(warnSpy).toHaveBeenCalled();
});
it('bulkReExtractCover aborts early when cancellation is requested', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
@@ -1961,6 +1981,26 @@ describe('BookService', () => {
expect(result).toEqual({ processed: 0, updated: 0 });
});
it('bulkReExtractCover returns zero counts without checking access when no books are requested', async () => {
const { service, bookRepo } = makeService();
await expect(service.bulkReExtractCover([], makeUser())).resolves.toEqual({ processed: 0, updated: 0 });
expect(bookRepo.findLibraryIdsByBookIds).not.toHaveBeenCalled();
});
it('bulkReExtractCover logs and rethrows access failures before loading files', async () => {
const { service } = makeService();
const warnSpy = vi.spyOn((service as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation();
const user = makeUser();
const accessError = new ForbiddenException('denied');
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockRejectedValue(accessError);
await expect(service.bulkReExtractCover([1], user)).rejects.toThrow(accessError);
expect(warnSpy).toHaveBeenCalled();
});
});
describe('bulk metadata actions', () => {
@@ -1994,6 +2034,20 @@ describe('BookService', () => {
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(3);
});
it('bulkSetRating logs score recalculation failures without interrupting the update', async () => {
const { service, bookRepo, scoreService } = makeService();
const user = makeUser({ id: 42 });
const warnSpy = vi.spyOn((service as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
scoreService.calculateAndSave.mockRejectedValue(new Error('score exploded'));
await service.bulkSetRating([3], 4, user);
await Promise.resolve();
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([3], 4, 42);
expect(warnSpy).toHaveBeenCalledWith('Score calculation failed for book 3: score exploded');
});
it('bulkSetMetadata updates a single metadata field and queues follow-up work', async () => {
const { service, bookRepo, fileWriteService, scoreService } = makeService();
const user = makeUser({ id: 11 });
@@ -2173,7 +2227,13 @@ describe('BookService', () => {
],
narratorRows: [{ id: 4, name: 'Narrator Name', sortName: null, displayOrder: 0 }],
});
userBookStatusService.findOne.mockResolvedValue({ status: 'reading' });
userBookStatusService.findOne.mockResolvedValue({
status: 'reading',
source: 'manual',
startedAt: '2026-01-03T00:00:00.000Z',
finishedAt: null,
updatedAt: '2026-01-04T00:00:00.000Z',
});
comicMetadataService.findByBookId.mockResolvedValue({ issueNumber: '1', teams: ['House Atreides'] });
bookRepo.findCollectionsByBookId.mockResolvedValue([{ id: 3, name: 'Favorites' }]);
bookRepo.findRatingByBookAndUser.mockResolvedValue(5);
@@ -2185,10 +2245,103 @@ describe('BookService', () => {
{ title: '01-intro', startMs: 0 },
{ title: '02-main', startMs: 30_000 },
]);
expect(result.readStatus).toEqual({
status: 'reading',
source: 'manual',
startedAt: '2026-01-03T00:00:00.000Z',
finishedAt: null,
updatedAt: '2026-01-04T00:00:00.000Z',
});
expect(result.files[0]?.role).toBe('primary');
expect(result.collections).toEqual([{ id: 3, name: 'Favorites' }]);
expect(result.lockedFields).toEqual(['title']);
expect(result.comicMetadata).toEqual(expect.objectContaining({ issueNumber: '1', teams: ['House Atreides'] }));
expect(result.rating).toBe(5);
expect(result.providerIds.google).toBe('g1');
});
it('preserves null personal detail fields when no user-specific state exists', async () => {
const { service, bookRepo, userBookStatusService, comicMetadataService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookRepo.findById.mockResolvedValue({
book: {
books: {
id: 12,
libraryId: 3,
primaryFileId: 400,
status: 'present',
folderPath: '/books/foundation',
addedAt: new Date('2026-02-01T00:00:00.000Z'),
},
libraries: { name: 'Main', formatPriority: null },
book_metadata: {
title: 'Foundation',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
pageCount: null,
seriesName: null,
seriesIndex: null,
rating: null,
coverSource: null,
lockedFields: null,
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
openLibraryId: null,
itunesId: null,
audibleId: null,
comicvineId: null,
chapters: null,
durationSeconds: null,
abridged: null,
lastWrittenAt: null,
metadataScore: null,
},
},
authorRows: [{ id: 2, name: 'Isaac Asimov', sortName: 'Asimov, Isaac' }],
genreRows: [{ name: 'Sci-Fi' }],
tagRows: [],
fileRows: [
{
id: 400,
format: 'epub',
role: 'content',
sizeBytes: 42,
absolutePath: '/books/foundation.epub',
createdAt: new Date('2026-02-01T00:00:00.000Z'),
durationSeconds: null,
},
],
narratorRows: [],
});
userBookStatusService.findOne.mockResolvedValue(null);
comicMetadataService.findByBookId.mockResolvedValue(null);
bookRepo.findCollectionsByBookId.mockResolvedValue([]);
bookRepo.findRatingByBookAndUser.mockResolvedValue(null);
const result = await service.getDetail(12, user);
expect(result.readStatus).toBeNull();
expect(result.rating).toBeNull();
expect(result.audioMetadata).toBeNull();
expect(result.collections).toEqual([]);
expect(result.comicMetadata).toBeNull();
expect(result.lockedFields).toEqual([]);
expect(result.formatPriority).toEqual([]);
expect(result.files).toEqual([
expect.objectContaining({
id: 400,
role: 'primary',
filename: 'foundation.epub',
}),
]);
});
it('getMetadataFromFile handles missing or unsupported primary files', async () => {
@@ -2201,6 +2354,14 @@ describe('BookService', () => {
await expect(service.getMetadataFromFile(1, user)).resolves.toEqual({});
});
it('getMetadataFromFile returns an empty object for unsupported but known file formats', async () => {
const { service, bookRepo } = makeService();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookRepo.findPrimaryFile.mockResolvedValue({ absolutePath: '/books/archive.azw3', format: 'azw3' });
await expect(service.getMetadataFromFile(5, makeUser())).resolves.toEqual({});
});
it('maps epub metadata to update payload shape', async () => {
const { service, bookRepo } = makeService();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
@@ -2348,6 +2509,21 @@ describe('BookService', () => {
});
describe('export edge cases', () => {
it('resolves missing sizeBytes from disk stat for projected exports', async () => {
const { service, bookRepo, appSettings } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{title}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/dune.epub', format: 'epub', sizeBytes: null }]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Dune' })]);
mockStat.mockResolvedValue({ size: 321 } as never);
const plan = await service.getExportFiles([1], user, 'primary');
expect(plan.projectedBytes).toBe(321);
expect(plan.files).toEqual([{ absolutePath: '/books/dune.epub', zipPath: 'Dune.epub', sizeBytes: 321 }]);
});
it('resolves missing sizeBytes from disk stat and throws when file is missing', async () => {
const { service, bookRepo, appSettings } = makeService();
const user = makeUser();
@@ -2360,6 +2536,18 @@ describe('BookService', () => {
await expect(service.getExportFiles([1], user, 'primary')).rejects.toThrow(NotFoundException);
});
it('rethrows unexpected stat failures while resolving export file sizes', async () => {
const { service, bookRepo, appSettings } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{title}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/secret.epub', format: 'epub', sizeBytes: null }]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Secret' })]);
mockStat.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
await expect(service.getExportFiles([1], user, 'primary')).rejects.toThrow('permission denied');
});
it('sorts equal-order export files by absolute path as final tie-breaker', async () => {
const { service, bookRepo, appSettings } = makeService();
const user = makeUser();
+14 -25
View File
@@ -56,6 +56,7 @@ import type { MetadataExportColumnMode } from './dto/metadata-export-options.dto
import { SaveProgressDto } from './dto/save-progress.dto';
import { UpsertAudioProgressDto } from './dto/upsert-audio-progress.dto';
import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
import { buildBookDetailSupplementalFields } from './utils/build-book-detail-supplemental-fields';
const METADATA_UPDATE_FAILPOINTS = [
'afterScalarUpdate',
@@ -1860,6 +1861,18 @@ export class BookService {
const meta = book.book_metadata;
const hasAudioFiles = fileRows.some((f) => f.format && isAudioFormat(f.format));
const resolvedChapters = this.resolveChapters(meta?.chapters as AudiobookChapter[] | null | undefined, fileRows);
const supplementalFields = buildBookDetailSupplementalFields({
readStatus,
hasAudioFiles,
narratorRows,
audioMeta: {
durationSeconds: meta?.durationSeconds,
abridged: meta?.abridged,
},
chapters: resolvedChapters,
comicMeta,
collections: collectionRows,
});
return {
id: book.books.id,
@@ -1907,32 +1920,8 @@ export class BookService {
})),
lastWrittenAt: meta?.lastWrittenAt ?? null,
metadataScore: meta?.metadataScore ?? null,
readStatus,
audioMetadata: hasAudioFiles
? {
narrators: narratorRows.map((n, i) => ({ id: n.id, name: n.name, sortName: n.sortName, displayOrder: i })),
durationSeconds: meta?.durationSeconds ?? null,
abridged: meta?.abridged ?? false,
chapters: resolvedChapters,
}
: null,
formatPriority: (book.libraries?.formatPriority as string[] | null) ?? [],
comicMetadata: comicMeta
? {
issueNumber: comicMeta.issueNumber ?? undefined,
volumeName: comicMeta.volumeName ?? undefined,
pencillers: comicMeta.pencillers ?? undefined,
inkers: comicMeta.inkers ?? undefined,
colorists: comicMeta.colorists ?? undefined,
letterers: comicMeta.letterers ?? undefined,
coverArtists: comicMeta.coverArtists ?? undefined,
characters: comicMeta.characters ?? undefined,
teams: comicMeta.teams ?? undefined,
locations: comicMeta.locations ?? undefined,
storyArcs: comicMeta.storyArcs ?? undefined,
}
: null,
collections: collectionRows,
...supplementalFields,
};
}
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import { buildBookDetailSupplementalFields } from './build-book-detail-supplemental-fields';
describe('buildBookDetailSupplementalFields', () => {
it('returns null supplemental state when no personal or media state exists', () => {
const result = buildBookDetailSupplementalFields({
readStatus: null,
hasAudioFiles: false,
narratorRows: [],
audioMeta: { durationSeconds: undefined, abridged: undefined },
chapters: null,
comicMeta: null,
collections: [],
});
expect(result).toEqual({
readStatus: null,
audioMetadata: null,
comicMetadata: null,
collections: [],
});
});
it('maps audio detail with stable narrator ordering and default abridged=false', () => {
const result = buildBookDetailSupplementalFields({
readStatus: {
status: 'reading',
source: 'manual',
startedAt: '2026-01-01T00:00:00.000Z',
finishedAt: null,
updatedAt: '2026-01-02T00:00:00.000Z',
},
hasAudioFiles: true,
narratorRows: [
{ id: 2, name: 'Narrator Two', sortName: 'Two, Narrator' },
{ id: 1, name: 'Narrator One', sortName: null },
],
audioMeta: { durationSeconds: 5400, abridged: null },
chapters: [{ title: 'Chapter 1', startMs: 0 }],
comicMeta: null,
collections: [{ id: 9, name: 'Favorites' }],
});
expect(result.readStatus).toEqual({
status: 'reading',
source: 'manual',
startedAt: '2026-01-01T00:00:00.000Z',
finishedAt: null,
updatedAt: '2026-01-02T00:00:00.000Z',
});
expect(result.audioMetadata).toEqual({
narrators: [
{ id: 2, name: 'Narrator Two', sortName: 'Two, Narrator', displayOrder: 0 },
{ id: 1, name: 'Narrator One', sortName: null, displayOrder: 1 },
],
durationSeconds: 5400,
abridged: false,
chapters: [{ title: 'Chapter 1', startMs: 0 }],
});
expect(result.collections).toEqual([{ id: 9, name: 'Favorites' }]);
});
it('maps comic metadata and drops null fields to undefined', () => {
const result = buildBookDetailSupplementalFields({
readStatus: null,
hasAudioFiles: false,
narratorRows: [],
audioMeta: { durationSeconds: null, abridged: false },
chapters: null,
comicMeta: {
issueNumber: '7',
volumeName: null,
pencillers: ['Penciller'],
inkers: null,
colorists: ['Colorist'],
letterers: null,
coverArtists: ['Cover Artist'],
characters: ['Hero'],
teams: ['Team'],
locations: null,
storyArcs: ['Arc'],
},
collections: [],
});
expect(result.comicMetadata).toEqual({
issueNumber: '7',
volumeName: undefined,
pencillers: ['Penciller'],
inkers: undefined,
colorists: ['Colorist'],
letterers: undefined,
coverArtists: ['Cover Artist'],
characters: ['Hero'],
teams: ['Team'],
locations: undefined,
storyArcs: ['Arc'],
});
});
});
@@ -0,0 +1,98 @@
import type { AudiobookChapter, ComicMetadataFields, NarratorRef, UserBookStatus } from '@bookorbit/types';
import type { BookDetailDto } from '../dto/book-detail.dto';
type NarratorRow = {
id: number;
name: string;
sortName: string | null;
};
type AudioMetaSource = {
durationSeconds: number | null | undefined;
abridged: boolean | null | undefined;
};
type ComicMetaSource = {
issueNumber?: string | null;
volumeName?: string | null;
pencillers?: string[] | null;
inkers?: string[] | null;
colorists?: string[] | null;
letterers?: string[] | null;
coverArtists?: string[] | null;
characters?: string[] | null;
teams?: string[] | null;
locations?: string[] | null;
storyArcs?: string[] | null;
} | null;
type CollectionRow = {
id: number;
name: string;
};
type SupplementalFieldInput = {
readStatus: UserBookStatus | null;
hasAudioFiles: boolean;
narratorRows: NarratorRow[];
audioMeta: AudioMetaSource;
chapters: AudiobookChapter[] | null;
comicMeta: ComicMetaSource;
collections: CollectionRow[];
};
function buildAudioMetadata(
hasAudioFiles: boolean,
narratorRows: NarratorRow[],
audioMeta: AudioMetaSource,
chapters: AudiobookChapter[] | null,
): BookDetailDto['audioMetadata'] {
if (!hasAudioFiles) return null;
return {
narrators: narratorRows.map<NarratorRef>((narrator, index) => ({
id: narrator.id,
name: narrator.name,
sortName: narrator.sortName,
displayOrder: index,
})),
durationSeconds: audioMeta.durationSeconds ?? null,
abridged: audioMeta.abridged ?? false,
chapters,
};
}
function buildComicMetadata(comicMeta: ComicMetaSource): ComicMetadataFields | null {
if (!comicMeta) return null;
return {
issueNumber: comicMeta.issueNumber ?? undefined,
volumeName: comicMeta.volumeName ?? undefined,
pencillers: comicMeta.pencillers ?? undefined,
inkers: comicMeta.inkers ?? undefined,
colorists: comicMeta.colorists ?? undefined,
letterers: comicMeta.letterers ?? undefined,
coverArtists: comicMeta.coverArtists ?? undefined,
characters: comicMeta.characters ?? undefined,
teams: comicMeta.teams ?? undefined,
locations: comicMeta.locations ?? undefined,
storyArcs: comicMeta.storyArcs ?? undefined,
};
}
export function buildBookDetailSupplementalFields({
readStatus,
hasAudioFiles,
narratorRows,
audioMeta,
chapters,
comicMeta,
collections,
}: SupplementalFieldInput): Pick<BookDetailDto, 'readStatus' | 'audioMetadata' | 'comicMetadata' | 'collections'> {
return {
readStatus: readStatus ?? null,
audioMetadata: buildAudioMetadata(hasAudioFiles, narratorRows, audioMeta, chapters),
comicMetadata: buildComicMetadata(comicMeta),
collections,
};
}