diff --git a/client/src/features/book/components/BookCoverCard.vue b/client/src/features/book/components/BookCoverCard.vue index f3a424e89..e28279897 100644 --- a/client/src/features/book/components/BookCoverCard.vue +++ b/client/src/features/book/components/BookCoverCard.vue @@ -390,7 +390,7 @@ async function handleSetStatus(status: ReadStatus) {

-
+
-
-
- -
- - - - - - - - - Open - - - - - Open - - - - Audiobook - {{ file.format?.toUpperCase() ?? '?' }} - Primary - - - - - - - - Download - - - - - Download - - - - Audiobook - {{ file.format?.toUpperCase() ?? '?' }} - Primary - - - All formats (ZIP) - - - - - - Book Details - - - - - - Metadata - - - - - Edit Metadata - - - - - Refresh Metadata - - - - - Regenerate Cover - - - - - - Add to Collection - - - - - Set Status - - - - - {{ opt.label }} - - - - - - - Send via Email - - - - - Delete - - - +
+
diff --git a/client/src/features/book/components/__tests__/DetailsTab.spec.ts b/client/src/features/book/components/__tests__/DetailsTab.spec.ts index a8a21e272..5311a7a22 100644 --- a/client/src/features/book/components/__tests__/DetailsTab.spec.ts +++ b/client/src/features/book/components/__tests__/DetailsTab.spec.ts @@ -77,7 +77,6 @@ function makeBook(overrides: Partial = {}): BookDetail { coverSource: null, providerIds: {}, authors: [], - narrators: [], genres: [], tags: [], files: [], @@ -85,9 +84,7 @@ function makeBook(overrides: Partial = {}): BookDetail { lastWrittenAt: null, metadataScore: null, readStatus: null, - durationSeconds: null, - abridged: false, - chapters: null, + audioMetadata: null, formatPriority: [], comicMetadata: null, ...overrides, diff --git a/client/src/features/book/components/detail/tabs/DetailsTab.vue b/client/src/features/book/components/detail/tabs/DetailsTab.vue index 5c10417f1..3e4ad765d 100644 --- a/client/src/features/book/components/detail/tabs/DetailsTab.vue +++ b/client/src/features/book/components/detail/tabs/DetailsTab.vue @@ -246,7 +246,7 @@ const openableFiles = computed(() => { }) const hasMultipleFiles = computed(() => openableFiles.value.length > 1) const authorLine = computed(() => props.book.authors.map((a) => a.name).join(', ') || null) -const narratorLine = computed(() => props.book.narrators?.map((n) => n.name).join(', ') || null) +const narratorLine = computed(() => props.book.audioMetadata?.narrators?.map((n) => n.name).join(', ') || null) const formats = computed(() => { const all = [...new Set(props.book.files.filter((f) => f.format && FORMAT_TO_GROUP[f.format]).map((f) => f.format!))] const priority = props.book.formatPriority @@ -1025,13 +1025,13 @@ watch(
Pages
{{ book.pageCount || '-' }}
-
+
Duration
-
{{ formatDuration(book.durationSeconds) }}
+
{{ formatDuration(book.audioMetadata.durationSeconds) }}
-
+
Edition
-
{{ book.abridged ? 'Abridged' : 'Unabridged' }}
+
{{ book.audioMetadata.abridged ? 'Abridged' : 'Unabridged' }}
ISBN
diff --git a/client/src/features/book/components/detail/tabs/EditMetadataTab.vue b/client/src/features/book/components/detail/tabs/EditMetadataTab.vue index a25f75eac..d6722cf6b 100644 --- a/client/src/features/book/components/detail/tabs/EditMetadataTab.vue +++ b/client/src/features/book/components/detail/tabs/EditMetadataTab.vue @@ -152,6 +152,11 @@ async function autoFill() { if (preview.comicMetadata.teams !== undefined) form.comicTeams = preview.comicMetadata.teams if (preview.comicMetadata.locations !== undefined) form.comicLocations = preview.comicMetadata.locations } + if (preview.audioMetadata) { + if (preview.audioMetadata.narrators !== undefined) form.narrators = preview.audioMetadata.narrators + if (preview.audioMetadata.durationSeconds !== undefined) form.durationSeconds = preview.audioMetadata.durationSeconds + if (preview.audioMetadata.abridged !== undefined) form.abridged = !!preview.audioMetadata.abridged + } if (preview.coverUrl) coverPanel.value?.setUrl(preview.coverUrl) } diff --git a/client/src/features/book/components/detail/tabs/MetadataSearchDrawer.vue b/client/src/features/book/components/detail/tabs/MetadataSearchDrawer.vue index 6ae4f4a7f..e25620e8a 100644 --- a/client/src/features/book/components/detail/tabs/MetadataSearchDrawer.vue +++ b/client/src/features/book/components/detail/tabs/MetadataSearchDrawer.vue @@ -37,9 +37,9 @@ const currentSource = computed(() => ({ isbn13: props.book.isbn13, authors: props.book.authors.map((a) => a.name), genres: props.book.genres, - narrators: props.book.narrators.map((n) => n.name), - durationSeconds: props.book.durationSeconds, - abridged: props.book.abridged, + narrators: props.book.audioMetadata?.narrators.map((n) => n.name) ?? [], + durationSeconds: props.book.audioMetadata?.durationSeconds ?? null, + abridged: props.book.audioMetadata?.abridged ?? null, })) const { diff --git a/client/src/features/book/composables/__tests__/useMetadataEditor.spec.ts b/client/src/features/book/composables/__tests__/useMetadataEditor.spec.ts new file mode 100644 index 000000000..9c82cc48a --- /dev/null +++ b/client/src/features/book/composables/__tests__/useMetadataEditor.spec.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BookDetail } from '@projectx/types' +import { useMetadataEditor } from '../useMetadataEditor' + +const apiMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/api', () => ({ + api: apiMock, +})) + +function makeBook(overrides: Partial = {}): BookDetail { + return { + id: 1, + libraryId: 1, + libraryName: 'Test Library', + status: 'present', + folderPath: '/books', + addedAt: '2026-01-01T00:00:00.000Z', + title: 'Test Book', + subtitle: null, + description: null, + isbn10: null, + isbn13: null, + publisher: null, + publishedYear: null, + language: null, + pageCount: null, + seriesName: null, + seriesIndex: null, + rating: null, + coverSource: null, + providerIds: {}, + authors: [], + genres: [], + tags: [], + files: [ + { + id: 11, + format: 'epub', + role: 'primary', + sizeBytes: 10, + absolutePath: '/books/test.epub', + createdAt: '2026-01-01T00:00:00.000Z', + filename: 'test.epub', + durationSeconds: null, + }, + ], + lastWrittenAt: null, + metadataScore: null, + readStatus: null, + audioMetadata: null, + formatPriority: [], + comicMetadata: null, + ...overrides, + } +} + +describe('useMetadataEditor', () => { + beforeEach(() => { + apiMock.mockReset() + }) + + it('omits audioMetadata when book has no audio files', async () => { + const book = makeBook() + apiMock.mockResolvedValue({ ok: true, json: async () => book }) + + const { load, save } = useMetadataEditor() + load(book) + await save(book.id) + + const [, req] = apiMock.mock.calls[0] as [string, RequestInit] + const payload = JSON.parse(String(req.body)) as Record + expect(payload.audioMetadata).toBeUndefined() + }) + + it('includes audioMetadata when book has audio files', async () => { + const book = makeBook({ + files: [ + { + id: 12, + format: 'm4b', + role: 'primary', + sizeBytes: 10, + absolutePath: '/books/test.m4b', + createdAt: '2026-01-01T00:00:00.000Z', + filename: 'test.m4b', + durationSeconds: 3600, + }, + ], + audioMetadata: { + narrators: [{ id: 2, name: 'Narrator One', sortName: null, displayOrder: 0 }], + durationSeconds: 3600, + abridged: false, + chapters: null, + }, + }) + apiMock.mockResolvedValue({ ok: true, json: async () => book }) + + const { load, save } = useMetadataEditor() + load(book) + await save(book.id) + + const [, req] = apiMock.mock.calls[0] as [string, RequestInit] + const payload = JSON.parse(String(req.body)) as { + audioMetadata?: { narrators?: string[]; durationSeconds?: number | null; abridged?: boolean } + } + expect(payload.audioMetadata).toEqual({ + narrators: ['Narrator One'], + durationSeconds: 3600, + abridged: false, + }) + }) +}) diff --git a/client/src/features/book/composables/useMetadataEditor.ts b/client/src/features/book/composables/useMetadataEditor.ts index a6a8f0492..4c2e2d0fa 100644 --- a/client/src/features/book/composables/useMetadataEditor.ts +++ b/client/src/features/book/composables/useMetadataEditor.ts @@ -1,6 +1,6 @@ import { computed, reactive, ref } from 'vue' import { api } from '@/lib/api' -import type { BookDetail } from '@projectx/types' +import { FORMAT_TO_GROUP, type BookDetail } from '@projectx/types' export function useMetadataEditor() { const saving = ref(false) @@ -47,6 +47,7 @@ export function useMetadataEditor() { }) const snapshot = ref(JSON.stringify(form)) + const includeAudioMetadata = ref(false) const isDirty = computed(() => JSON.stringify(form) !== snapshot.value) @@ -66,9 +67,9 @@ export function useMetadataEditor() { form.authors = book.authors.map((a) => a.name) form.genres = [...book.genres] form.tags = [...book.tags] - form.narrators = book.narrators?.map((n) => n.name) ?? [] - form.durationSeconds = book.durationSeconds ?? null - form.abridged = book.abridged ?? false + form.narrators = book.audioMetadata?.narrators?.map((n) => n.name) ?? [] + form.durationSeconds = book.audioMetadata?.durationSeconds ?? null + form.abridged = book.audioMetadata?.abridged ?? false form.googleBooksId = book.providerIds.google ?? null form.goodreadsId = book.providerIds.goodreads ?? null form.amazonId = book.providerIds.amazon ?? null @@ -89,6 +90,7 @@ export function useMetadataEditor() { form.comicCharacters = cm?.characters ?? [] form.comicTeams = cm?.teams ?? [] form.comicLocations = cm?.locations ?? [] + includeAudioMetadata.value = book.audioMetadata != null || book.files.some((f) => f.format != null && FORMAT_TO_GROUP[f.format] === 'audio') snapshot.value = JSON.stringify(form) error.value = null } @@ -112,9 +114,12 @@ export function useMetadataEditor() { comicCharacters, comicTeams, comicLocations, + narrators, + durationSeconds, + abridged, ...rest } = form - return { + const payload = { ...rest, comicMetadata: { issueNumber: comicIssueNumber ?? undefined, @@ -130,6 +135,17 @@ export function useMetadataEditor() { locations: comicLocations, }, } + if (includeAudioMetadata.value) { + return { + ...payload, + audioMetadata: { + narrators, + durationSeconds, + abridged, + }, + } + } + return payload } async function save(bookId: number): Promise { diff --git a/client/src/features/book/composables/useRefreshMetadata.ts b/client/src/features/book/composables/useRefreshMetadata.ts index c50b7eaef..95223304a 100644 --- a/client/src/features/book/composables/useRefreshMetadata.ts +++ b/client/src/features/book/composables/useRefreshMetadata.ts @@ -25,6 +25,12 @@ export interface MetadataRefreshPreview { itunesId?: string | null audibleId?: string | null comicvineId?: string | null + audioMetadata?: { + narrators?: string[] + durationSeconds?: number | null + abridged?: boolean | null + chapters?: { title: string; startMs: number; durationMs?: number | null }[] + } comicMetadata?: ComicMetadataFields } diff --git a/client/src/features/reader/audiobook/AudiobookReaderView.vue b/client/src/features/reader/audiobook/AudiobookReaderView.vue index abaded82a..6083d737d 100644 --- a/client/src/features/reader/audiobook/AudiobookReaderView.vue +++ b/client/src/features/reader/audiobook/AudiobookReaderView.vue @@ -401,7 +401,7 @@ function setSleepTimer(minutes: number) { function setEndOfChapterSleep() { // If there are no chapters to watch, fall back to a 30-minute timer. - if (!detail.value?.chapters?.length) { + if (!detail.value?.audioMetadata?.chapters?.length) { setSleepTimer(30) return } @@ -467,8 +467,8 @@ const progressPct = computed(() => { }) const chapterTicks = computed(() => { - if (!detail.value?.chapters?.length || !totalBookDuration.value) return [] - return detail.value.chapters + if (!detail.value?.audioMetadata?.chapters?.length || !totalBookDuration.value) return [] + return detail.value.audioMetadata.chapters .filter((ch) => ch.startMs > 0) .map((ch) => ({ startMs: ch.startMs, @@ -487,7 +487,7 @@ const bookmarkTicks = computed(() => { }) const chapterDurations = computed(() => { - const chapters = detail.value?.chapters + const chapters = detail.value?.audioMetadata?.chapters if (!chapters?.length) return [] return chapters.map((ch, i) => { const nextStartMs = chapters[i + 1]?.startMs ?? totalBookDuration.value * 1000 @@ -498,7 +498,7 @@ const chapterDurations = computed(() => { const SPEEDS = [0.75, 1.0, 1.25, 1.5, 2.0] as const const currentChapter = computed(() => { - const chapters = detail.value?.chapters + const chapters = detail.value?.audioMetadata?.chapters if (!chapters?.length) return null const pos = absolutePositionMs.value let current: AudiobookChapter | null = null @@ -511,7 +511,7 @@ const currentChapter = computed(() => { const scrubberHoverChapter = computed(() => { if (scrubberHoverSeconds.value === null) return null - const chapters = detail.value?.chapters + const chapters = detail.value?.audioMetadata?.chapters if (!chapters?.length) return null const posMs = scrubberHoverSeconds.value * 1000 let cur: AudiobookChapter | null = null @@ -570,8 +570,8 @@ onMounted(async () => { ]) if (!mounted) return detail.value = detailRes - if (detailRes.chapters) { - detailRes.chapters.sort((a, b) => a.startMs - b.startMs) + if (detailRes.audioMetadata?.chapters) { + detailRes.audioMetadata.chapters.sort((a, b) => a.startMs - b.startMs) } if ('mediaSession' in navigator) { @@ -658,8 +658,8 @@ onMounted(async () => {

{{ detail.title ?? 'Untitled' }}

-

- {{ detail.narrators.map((n) => n.name).join(', ') }} +

+ {{ detail.audioMetadata.narrators.map((n) => n.name).join(', ') }}

@@ -993,9 +993,9 @@ onMounted(async () => {
-
+
+
+ `, +}) + +const EditMetadataTabStub = defineComponent({ + name: 'EditMetadataTab', + props: { + book: { + type: Object as PropType, + required: true, + }, + }, + template: '
{{ book.rating }}
', +}) + +describe('BookDetailView', () => { + beforeEach(() => { + mockState.route = reactive({ + params: { bookId: '217' }, + query: { tab: 'details' }, + }) + mockState.detail = ref(makeBook()) + mockState.loading = ref(false) + mockState.fetch.mockReset() + }) + + it('keeps updated rating when switching details -> edit -> details tabs', async () => { + const wrapper = mount(BookDetailView, { + global: { + stubs: { + BookDetailLayout: { template: '
' }, + DetailsTab: DetailsTabStub, + EditMetadataTab: EditMetadataTabStub, + FilesTab: { template: '
' }, + }, + }, + }) + + expect(wrapper.get('[data-test="details-rating"]').text()).toBe('2') + + await wrapper.get('[data-test="details-save"]').trigger('click') + await nextTick() + expect(wrapper.get('[data-test="details-rating"]').text()).toBe('4') + + mockState.route.query = { tab: 'edit' } + await nextTick() + expect(wrapper.get('[data-test="edit-rating"]').text()).toBe('4') + + mockState.route.query = { tab: 'details' } + await nextTick() + expect(wrapper.get('[data-test="details-rating"]').text()).toBe('4') + }) +}) diff --git a/packages/types/src/book.ts b/packages/types/src/book.ts index fed8ddbf9..a3fff80ea 100644 --- a/packages/types/src/book.ts +++ b/packages/types/src/book.ts @@ -60,6 +60,13 @@ export type BookDetailFile = { export type ProviderIds = Partial>; +export type AudioMetadata = { + narrators: NarratorRef[]; + durationSeconds: number | null; + abridged: boolean; + chapters: AudiobookChapter[] | null; +}; + export type BookDetail = { id: number; libraryId: number; @@ -82,16 +89,13 @@ export type BookDetail = { coverSource: "extracted" | "custom" | null; providerIds: ProviderIds; authors: { id: number; name: string; sortName: string | null }[]; - narrators: NarratorRef[]; genres: string[]; tags: string[]; files: BookDetailFile[]; lastWrittenAt: string | null; metadataScore: number | null; readStatus: UserBookStatus | null; - durationSeconds: number | null; - abridged: boolean; - chapters: AudiobookChapter[] | null; + audioMetadata: AudioMetadata | null; formatPriority: string[]; comicMetadata: ComicMetadataFields | null; }; diff --git a/server/src/modules/book/book.service.test.ts b/server/src/modules/book/book.service.test.ts index d4beaa3f1..35b2278d1 100644 --- a/server/src/modules/book/book.service.test.ts +++ b/server/src/modules/book/book.service.test.ts @@ -443,6 +443,46 @@ describe('BookService', () => { }); }); + it('refreshMetadata preview nests audiobook fields under audioMetadata', async () => { + const { service, bookRepo, pipeline } = makeService(); + const user = makeUser(); + bookRepo.findById.mockResolvedValue({ + book: { + books: { id: 1, libraryId: 7 }, + book_metadata: { title: 'Old', isbn13: null, isbn10: null }, + }, + authorRows: [{ id: 1, name: 'Author One', sortName: null }], + genreRows: [], + }); + pipeline.runWithSources.mockResolvedValue({ + resolved: { + title: 'Resolved', + narrators: ['Narrator One'], + duration: 3600, + abridged: true, + chapters: [{ title: 'Chapter 1', startMs: 0 }], + }, + sources: {}, + providerIds: {}, + }); + + const result = await service.refreshMetadata(1, true, user); + + expect(result).toEqual({ + title: 'Resolved', + audioMetadata: { + narrators: ['Narrator One'], + durationSeconds: 3600, + abridged: true, + chapters: [{ title: 'Chapter 1', startMs: 0 }], + }, + }); + expect((result as Record).narrators).toBeUndefined(); + expect((result as Record).duration).toBeUndefined(); + expect((result as Record).abridged).toBeUndefined(); + expect((result as Record).chapters).toBeUndefined(); + }); + it('refreshMetadata persists provider ids returned by pipeline', async () => { const { service, bookRepo, pipeline } = makeService(); const user = makeUser(); @@ -478,6 +518,48 @@ describe('BookService', () => { ); }); + it('refreshMetadata persists audiobook fields under audioMetadata', async () => { + const { service, bookRepo, pipeline } = makeService(); + const user = makeUser(); + bookRepo.findById.mockResolvedValue({ + book: { + books: { id: 1, libraryId: 7 }, + book_metadata: { title: 'Old', isbn13: null, isbn10: null }, + }, + authorRows: [{ id: 1, name: 'Author One', sortName: null }], + genreRows: [], + }); + pipeline.runWithSources.mockResolvedValue({ + resolved: { + title: 'Resolved', + narrators: ['Narrator One'], + duration: 3600, + abridged: false, + chapters: [{ title: 'Chapter 1', startMs: 0 }], + }, + sources: {}, + providerIds: {}, + }); + + const updateSpy = vi.spyOn(service, 'updateMetadata').mockResolvedValue({ id: 1 } as never); + + await service.refreshMetadata(1, false, user); + + expect(updateSpy).toHaveBeenCalledWith( + 1, + { + title: 'Resolved', + audioMetadata: { + narrators: ['Narrator One'], + durationSeconds: 3600, + abridged: false, + chapters: [{ title: 'Chapter 1', startMs: 0 }], + }, + }, + user, + ); + }); + it('refreshMetadata persists comic metadata returned by pipeline', async () => { const { service, bookRepo, pipeline } = makeService(); const user = makeUser(); diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts index 228189d38..02ee55996 100644 --- a/server/src/modules/book/book.service.ts +++ b/server/src/modules/book/book.service.ts @@ -351,9 +351,12 @@ export class BookService { if ('openLibraryId' in dto) scalarFields.openLibraryId = dto.openLibraryId ?? null; if ('itunesId' in dto) scalarFields.itunesId = dto.itunesId ?? null; if ('audibleId' in dto) scalarFields.audibleId = dto.audibleId ?? null; - if ('durationSeconds' in dto) scalarFields.durationSeconds = dto.durationSeconds ?? null; - if ('abridged' in dto) scalarFields.abridged = dto.abridged ?? false; if ('comicvineId' in dto) scalarFields.comicvineId = dto.comicvineId ?? null; + if (dto.audioMetadata) { + if ('durationSeconds' in dto.audioMetadata) scalarFields.durationSeconds = dto.audioMetadata.durationSeconds ?? null; + if ('abridged' in dto.audioMetadata) scalarFields.abridged = dto.audioMetadata.abridged ?? false; + if ('chapters' in dto.audioMetadata) scalarFields.chapters = dto.audioMetadata.chapters ?? null; + } const scalarFieldCount = Object.keys(scalarFields).length; if (scalarFieldCount > 0) { @@ -371,8 +374,8 @@ export class BookService { dto.authors.map((name) => ({ name, sortName: null })), ); } - if (dto.narrators !== undefined) { - await this.narratorService.replaceForBook(id, dto.narrators); + if (dto.audioMetadata?.narrators !== undefined) { + await this.narratorService.replaceForBook(id, dto.audioMetadata.narrators); } if (dto.genres !== undefined) { await this.metadataService.replaceGenres(id, dto.genres); @@ -386,7 +389,7 @@ export class BookService { this.scoreService.calculateAndSave(id).catch((err: Error) => this.logger.warn(`Score calculation failed for book ${id}: ${err.message}`)); const detail = await this.getDetail(id, user); this.logger.log( - `[${event}] [end] bookId=${id} durationMs=${Date.now() - startedAt} scalarFields=${scalarFieldCount} authorsUpdated=${dto.authors !== undefined} narratorsUpdated=${dto.narrators !== undefined} genresUpdated=${dto.genres !== undefined} tagsUpdated=${dto.tags !== undefined} comicMetadataUpdated=${dto.comicMetadata !== undefined} - metadata update completed`, + `[${event}] [end] bookId=${id} durationMs=${Date.now() - startedAt} scalarFields=${scalarFieldCount} authorsUpdated=${dto.authors !== undefined} narratorsUpdated=${dto.audioMetadata?.narrators !== undefined} genresUpdated=${dto.genres !== undefined} tagsUpdated=${dto.tags !== undefined} audioMetadataUpdated=${dto.audioMetadata !== undefined} comicMetadataUpdated=${dto.comicMetadata !== undefined} - metadata update completed`, ); return detail; } catch (err) { @@ -597,7 +600,29 @@ export class BookService { itunesId?: string; audibleId?: string; comicvineId?: string; + audioMetadata?: { + narrators?: string[]; + durationSeconds?: number | null; + abridged?: boolean | null; + chapters?: AudiobookChapter[]; + }; } = { ...resolved }; + if ( + previewResult.narrators !== undefined || + previewResult.duration !== undefined || + previewResult.abridged !== undefined || + previewResult.chapters !== undefined + ) { + previewResult.audioMetadata = {}; + if (previewResult.narrators !== undefined) previewResult.audioMetadata.narrators = previewResult.narrators as string[]; + if (previewResult.duration !== undefined) previewResult.audioMetadata.durationSeconds = previewResult.duration as number | null; + if (previewResult.abridged !== undefined) previewResult.audioMetadata.abridged = previewResult.abridged as boolean | null; + if (previewResult.chapters !== undefined) previewResult.audioMetadata.chapters = previewResult.chapters as AudiobookChapter[]; + delete (previewResult as Record).narrators; + delete (previewResult as Record).duration; + delete (previewResult as Record).abridged; + delete (previewResult as Record).chapters; + } this.applyResolvedProviderIds(previewResult, resolvedProviderIds); this.logger.log( `[${event}] [end] bookId=${id} preview=true durationMs=${Date.now() - startedAt} resolvedFields=${Object.keys(previewResult).length} - refresh metadata completed`, @@ -618,8 +643,13 @@ export class BookService { if (r.pageCount !== undefined) dto.pageCount = r.pageCount as number | null; if (r.seriesName !== undefined) dto.seriesName = r.seriesName as string | null; if (r.seriesIndex !== undefined) dto.seriesIndex = r.seriesIndex as number | null; - if (r.duration !== undefined) dto.durationSeconds = r.duration as number | null; - if (r.abridged !== undefined) dto.abridged = r.abridged as boolean | null; + if (r.narrators !== undefined || r.duration !== undefined || r.abridged !== undefined || r.chapters !== undefined) { + dto.audioMetadata = {}; + if (r.narrators !== undefined) dto.audioMetadata.narrators = r.narrators as string[]; + if (r.duration !== undefined) dto.audioMetadata.durationSeconds = r.duration as number | null; + if (r.abridged !== undefined) dto.audioMetadata.abridged = r.abridged as boolean | null; + if (r.chapters !== undefined) dto.audioMetadata.chapters = r.chapters as NonNullable; + } if (r.comicMetadata !== undefined) dto.comicMetadata = r.comicMetadata as UpdateBookMetadataDto['comicMetadata']; this.applyResolvedProviderIds(dto, resolvedProviderIds); @@ -800,6 +830,8 @@ export class BookService { const { book, authorRows, genreRows, tagRows, fileRows, narratorRows } = result; 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); return { id: book.books.id, @@ -832,7 +864,6 @@ export class BookService { [MetadataProviderKey.COMICVINE]: meta?.comicvineId ?? null, }, authors: authorRows, - narrators: narratorRows.map((n, i) => ({ id: n.id, name: n.name, sortName: n.sortName, displayOrder: i })), genres: genreRows.map((g) => g.name), tags: tagRows.map((t) => t.name), files: fileRows.map((f) => ({ @@ -848,9 +879,14 @@ export class BookService { lastWrittenAt: meta?.lastWrittenAt ?? null, metadataScore: meta?.metadataScore ?? null, readStatus, - durationSeconds: meta?.durationSeconds ?? null, - abridged: meta?.abridged ?? false, - chapters: this.resolveChapters(meta?.chapters as AudiobookChapter[] | null | undefined, fileRows), + 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 ? { diff --git a/server/src/modules/book/dto/book-detail.dto.ts b/server/src/modules/book/dto/book-detail.dto.ts index 8aac6a354..8066c991a 100644 --- a/server/src/modules/book/dto/book-detail.dto.ts +++ b/server/src/modules/book/dto/book-detail.dto.ts @@ -11,6 +11,13 @@ export class BookFileDto { durationSeconds: number | null; } +export class AudioMetadataDto { + narrators: NarratorRef[]; + durationSeconds: number | null; + abridged: boolean; + chapters: AudiobookChapter[] | null; +} + export class BookDetailDto { id: number; libraryId: number; @@ -33,16 +40,13 @@ export class BookDetailDto { coverSource: 'extracted' | 'custom' | null; providerIds: ProviderIds; authors: { id: number; name: string; sortName: string | null }[]; - narrators: NarratorRef[]; genres: string[]; tags: string[]; files: BookFileDto[]; lastWrittenAt: Date | null; metadataScore: number | null; readStatus: UserBookStatus | null; - durationSeconds: number | null; - abridged: boolean; - chapters: AudiobookChapter[] | null; + audioMetadata: AudioMetadataDto | null; formatPriority: string[]; comicMetadata: ComicMetadataFields | null; } diff --git a/server/src/modules/book/dto/book-dto-validation.test.ts b/server/src/modules/book/dto/book-dto-validation.test.ts index c0f6a4b5e..5bdb302b6 100644 --- a/server/src/modules/book/dto/book-dto-validation.test.ts +++ b/server/src/modules/book/dto/book-dto-validation.test.ts @@ -17,6 +17,11 @@ async function errorsFor(cls: new () => T, value: Record(cls: new () => T, value: Record) { + const dto = plainToInstance(cls, value); + return validate(dto, { whitelist: true, forbidNonWhitelisted: true }); +} + describe('Book DTO validation', () => { it('validates book id array DTOs require non-empty integer arrays', async () => { expect((await errorsFor(BulkBookIdsDto, { bookIds: [1, 2] })).length).toBe(0); @@ -86,6 +91,29 @@ describe('Book DTO validation', () => { expect((await errorsFor(UpdateBookMetadataDto, { language: 'english-too-long' })).length).toBeGreaterThan(0); }); + it('rejects legacy top-level audiobook fields and accepts nested audioMetadata', async () => { + const legacyErrors = await strictErrorsFor(UpdateBookMetadataDto, { + narrators: ['Narrator One'], + durationSeconds: 3600, + abridged: true, + chapters: [{ title: 'Chapter 1', startMs: 0 }], + }); + + expect(legacyErrors.length).toBeGreaterThan(0); + expect(legacyErrors.map((e) => e.property)).toEqual(expect.arrayContaining(['narrators', 'durationSeconds', 'abridged', 'chapters'])); + + const nestedErrors = await strictErrorsFor(UpdateBookMetadataDto, { + audioMetadata: { + narrators: ['Narrator One'], + durationSeconds: 3600, + abridged: true, + chapters: [{ title: 'Chapter 1', startMs: 0 }], + }, + }); + + expect(nestedErrors.length).toBe(0); + }); + it('allows optional null rating reset and rejects out-of-range values', async () => { expect((await errorsFor(UpdateRatingDto, { rating: null })).length).toBe(0); expect((await errorsFor(UpdateRatingDto, { rating: 3 })).length).toBe(0); diff --git a/server/src/modules/book/dto/update-book-metadata.dto.ts b/server/src/modules/book/dto/update-book-metadata.dto.ts index efbe0ef9c..84328d27d 100644 --- a/server/src/modules/book/dto/update-book-metadata.dto.ts +++ b/server/src/modules/book/dto/update-book-metadata.dto.ts @@ -1,6 +1,19 @@ import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; +export class AudiobookChapterDto { + @IsString() title!: string; + @IsInt() @Min(0) startMs!: number; + @IsOptional() @IsInt() @Min(0) durationMs?: number | null; +} + +export class AudioMetadataDto { + @IsOptional() @IsArray() @IsString({ each: true }) narrators?: string[]; + @IsOptional() @IsInt() @Min(0) durationSeconds?: number | null; + @IsOptional() @IsBoolean() abridged?: boolean | null; + @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AudiobookChapterDto) chapters?: AudiobookChapterDto[] | null; +} + export class ComicMetadataDto { @IsOptional() @IsString() @MaxLength(50) issueNumber?: string | null; @IsOptional() @IsString() @MaxLength(500) volumeName?: string | null; @@ -29,7 +42,6 @@ export class UpdateBookMetadataDto { @IsOptional() @IsString() @MaxLength(13) isbn13?: string | null; @IsOptional() @IsInt() @Min(1) @Max(5) rating?: number | null; @IsOptional() @IsArray() @IsString({ each: true }) authors?: string[]; - @IsOptional() @IsArray() @IsString({ each: true }) narrators?: string[]; @IsOptional() @IsArray() @IsString({ each: true }) genres?: string[]; @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[]; @IsOptional() @IsString() @MaxLength(50) googleBooksId?: string | null; @@ -39,8 +51,7 @@ export class UpdateBookMetadataDto { @IsOptional() @IsString() @MaxLength(50) openLibraryId?: string | null; @IsOptional() @IsString() @MaxLength(50) itunesId?: string | null; @IsOptional() @IsString() @MaxLength(20) audibleId?: string | null; - @IsOptional() @IsInt() @Min(0) durationSeconds?: number | null; - @IsOptional() @IsBoolean() abridged?: boolean | null; + @IsOptional() @ValidateNested() @Type(() => AudioMetadataDto) audioMetadata?: AudioMetadataDto; @IsOptional() @IsString() @MaxLength(50) comicvineId?: string | null; @IsOptional() @ValidateNested() @Type(() => ComicMetadataDto) comicMetadata?: ComicMetadataDto; }