mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: harden nested audio metadata contract and UI consistency
This commit is contained in:
@@ -390,7 +390,7 @@ async function handleSetStatus(status: ReadStatus) {
|
||||
</p>
|
||||
<div v-else class="flex-1" />
|
||||
|
||||
<div v-if="!showAuthorOnHover" class="shrink-0">
|
||||
<div class="shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="p-1.5 rounded-full bg-black/40 hover:bg-white/20 transition-colors text-white shrink-0">
|
||||
@@ -506,125 +506,10 @@ async function handleSetStatus(status: ReadStatus) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showAuthorOnHover" class="flex items-center justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<button class="text-[10px] text-white/70 truncate hover:underline text-left block w-full" @click.stop="openAuthorBrowse">
|
||||
{{ authorLine }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="p-1.5 rounded-full bg-black/40 hover:bg-white/20 transition-colors text-white shrink-0">
|
||||
<MoreHorizontal class="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem v-if="openableFiles.length <= 1 && primaryFile && !isMissing" @click="openFile(primaryFile)">
|
||||
<BookOpen class="size-4 mr-2" />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub v-else-if="openableFiles.length > 1 && !isMissing">
|
||||
<DropdownMenuSubTrigger>
|
||||
<BookOpen class="size-4 mr-2" />
|
||||
Open
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="openFile(file)">
|
||||
<span v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</span>
|
||||
<span v-else>{{ file.format?.toUpperCase() ?? '?' }}</span>
|
||||
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="ml-auto pl-4 text-[10px] text-primary/70">Primary</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<!-- Download submenu -->
|
||||
<DropdownMenuItem
|
||||
v-if="hasPermission('library_download') && openableFiles.length === 1 && primaryFile"
|
||||
@click="handleDownloadFile(primaryFile)"
|
||||
>
|
||||
<Download class="size-4 mr-2" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub v-else-if="hasPermission('library_download') && openableFiles.length > 1">
|
||||
<DropdownMenuSubTrigger>
|
||||
<Download class="size-4 mr-2" />
|
||||
Download
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="handleDownloadFile(file)">
|
||||
<span v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</span>
|
||||
<span v-else>{{ file.format?.toUpperCase() ?? '?' }}</span>
|
||||
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="ml-auto pl-4 text-[10px] text-primary/70">Primary</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem @click="handleExportAll"> All formats (ZIP) </DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuItem @click="router.push({ name: 'book-detail', params: { bookId: book.id } })">
|
||||
<ExternalLink class="size-4 mr-2" />
|
||||
Book Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator v-if="hasPermission('library_edit_metadata')" />
|
||||
<DropdownMenuSub v-if="hasPermission('library_edit_metadata')">
|
||||
<DropdownMenuSubTrigger>
|
||||
<Pencil class="size-4 mr-2" />
|
||||
Metadata
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem @click="router.push({ name: 'book-detail', params: { bookId: book.id }, query: { tab: 'edit' } })">
|
||||
<Pencil class="size-4 mr-2" />
|
||||
Edit Metadata
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem :disabled="anyRefreshing" @click="refreshWithFeedback(book.id)">
|
||||
<Loader2 v-if="anyRefreshing" class="size-4 mr-2 animate-spin" />
|
||||
<RefreshCw v-else class="size-4 mr-2" />
|
||||
Refresh Metadata
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem :disabled="reExtractingCover" @click="reExtractCover()">
|
||||
<Loader2 v-if="reExtractingCover" class="size-4 mr-2 animate-spin" />
|
||||
<Image v-else class="size-4 mr-2" />
|
||||
Regenerate Cover
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem @click="emit('action', 'add-to-collection')">
|
||||
<FolderPlus class="size-4 mr-2" />
|
||||
Add to Collection
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<component
|
||||
:is="STATUS_ICONS[localReadStatus ?? 'unread']"
|
||||
class="size-4 mr-2"
|
||||
:class="STATUS_COLORS[localReadStatus ?? 'unread']"
|
||||
/>
|
||||
Set Status
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem v-for="opt in STATUS_OPTIONS" :key="opt.value" @click="handleSetStatus(opt.value)">
|
||||
<component :is="STATUS_ICONS[opt.value]" class="size-4 mr-2" :class="STATUS_COLORS[opt.value]" />
|
||||
{{ opt.label }}
|
||||
<Check v-if="localReadStatus === opt.value" class="size-3 ml-auto text-primary" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem v-if="hasPermission('email_send')" @click="showSendDialog = true">
|
||||
<Send class="size-4 mr-2" />
|
||||
Send via Email
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
v-if="hasPermission('library_delete_books')"
|
||||
class="text-destructive focus:text-destructive"
|
||||
@click="emit('action', 'delete')"
|
||||
>
|
||||
<Trash2 class="size-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div v-if="showAuthorOnHover" class="min-w-0">
|
||||
<button class="text-[10px] text-white/70 truncate hover:underline text-left block w-full" @click.stop="openAuthorBrowse">
|
||||
{{ authorLine }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,6 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
|
||||
coverSource: null,
|
||||
providerIds: {},
|
||||
authors: [],
|
||||
narrators: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
files: [],
|
||||
@@ -85,9 +84,7 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
|
||||
lastWrittenAt: null,
|
||||
metadataScore: null,
|
||||
readStatus: null,
|
||||
durationSeconds: null,
|
||||
abridged: false,
|
||||
chapters: null,
|
||||
audioMetadata: null,
|
||||
formatPriority: [],
|
||||
comicMetadata: null,
|
||||
...overrides,
|
||||
|
||||
@@ -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(
|
||||
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Pages</dt>
|
||||
<dd class="text-sm text-foreground mt-0.5">{{ book.pageCount || '-' }}</dd>
|
||||
</div>
|
||||
<div v-if="book.durationSeconds != null">
|
||||
<div v-if="book.audioMetadata?.durationSeconds != null">
|
||||
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Duration</dt>
|
||||
<dd class="text-sm text-foreground mt-0.5">{{ formatDuration(book.durationSeconds) }}</dd>
|
||||
<dd class="text-sm text-foreground mt-0.5">{{ formatDuration(book.audioMetadata.durationSeconds) }}</dd>
|
||||
</div>
|
||||
<div v-if="book.durationSeconds != null">
|
||||
<div v-if="book.audioMetadata?.durationSeconds != null">
|
||||
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">Edition</dt>
|
||||
<dd class="text-sm text-foreground mt-0.5">{{ book.abridged ? 'Abridged' : 'Unabridged' }}</dd>
|
||||
<dd class="text-sm text-foreground mt-0.5">{{ book.audioMetadata.abridged ? 'Abridged' : 'Unabridged' }}</dd>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<dt class="text-[10px] uppercase tracking-wider font-medium text-muted-foreground">ISBN</dt>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -37,9 +37,9 @@ const currentSource = computed<MetadataSource>(() => ({
|
||||
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 {
|
||||
|
||||
@@ -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> = {}): 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<string, unknown>
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<BookDetail | null> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<number[]>(() => {
|
||||
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<number[]>(() => {
|
||||
const SPEEDS = [0.75, 1.0, 1.25, 1.5, 2.0] as const
|
||||
|
||||
const currentChapter = computed<AudiobookChapter | null>(() => {
|
||||
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<AudiobookChapter | null>(() => {
|
||||
|
||||
const scrubberHoverChapter = computed<AudiobookChapter | null>(() => {
|
||||
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 () => {
|
||||
</button>
|
||||
<div class="flex-1 min-w-0 px-1">
|
||||
<p class="text-sm font-semibold truncate">{{ detail.title ?? 'Untitled' }}</p>
|
||||
<p v-if="detail.narrators.length" class="text-xs text-white/55 truncate">
|
||||
{{ detail.narrators.map((n) => n.name).join(', ') }}
|
||||
<p v-if="detail.audioMetadata?.narrators.length" class="text-xs text-white/55 truncate">
|
||||
{{ detail.audioMetadata.narrators.map((n) => n.name).join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Bookmark toggle -->
|
||||
@@ -993,9 +993,9 @@ onMounted(async () => {
|
||||
|
||||
<!-- Chapters list -->
|
||||
<div v-if="chaptersTab === 'chapters'" class="flex-1 overflow-y-auto">
|
||||
<div v-if="detail.chapters?.length">
|
||||
<div v-if="detail.audioMetadata?.chapters?.length">
|
||||
<button
|
||||
v-for="(chapter, i) in detail.chapters"
|
||||
v-for="(chapter, i) in detail.audioMetadata.chapters"
|
||||
:key="chapter.startMs"
|
||||
class="w-full text-left px-5 py-3 hover:bg-white/10 transition-colors text-sm"
|
||||
:class="currentChapter?.startMs === chapter.startMs ? 'text-white font-semibold' : 'text-white/65'"
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick, reactive, ref, type PropType } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
import BookDetailView from '../BookDetailView.vue'
|
||||
|
||||
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(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>()
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => mockState.route,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/book/composables/useBookDetail', () => ({
|
||||
useBookDetail: () => ({
|
||||
detail: mockState.detail,
|
||||
loading: mockState.loading,
|
||||
fetch: mockState.fetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/book/composables/useBookEvents', () => ({
|
||||
useBookEvents: () => ({
|
||||
onBookMissing: () => undefined,
|
||||
onBookRestored: () => undefined,
|
||||
onBookMoved: () => undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/scanner/composables/useScanProgress', () => ({
|
||||
useScanProgress: () => ({ subscribeLibrary: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/usePageTitle', () => ({
|
||||
usePageTitle: () => undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/composables/usePermissions', () => ({
|
||||
usePermissions: () => ({ hasPermission: () => true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/library/composables/useLibraries', () => ({
|
||||
useLibraries: () => ({ libraries: ref([]) }),
|
||||
}))
|
||||
|
||||
function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
|
||||
return {
|
||||
id: 217,
|
||||
libraryId: 1,
|
||||
libraryName: 'Test Library',
|
||||
status: 'present',
|
||||
folderPath: '/books',
|
||||
addedAt: '2026-01-01T00:00:00.000Z',
|
||||
title: 'Rating Test',
|
||||
subtitle: null,
|
||||
description: null,
|
||||
isbn10: null,
|
||||
isbn13: null,
|
||||
publisher: null,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
pageCount: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
rating: 2,
|
||||
coverSource: null,
|
||||
providerIds: {},
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
files: [],
|
||||
lastWrittenAt: null,
|
||||
metadataScore: null,
|
||||
readStatus: null,
|
||||
audioMetadata: null,
|
||||
formatPriority: [],
|
||||
comicMetadata: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const DetailsTabStub = defineComponent({
|
||||
name: 'DetailsTab',
|
||||
props: {
|
||||
book: {
|
||||
type: Object as PropType<BookDetail>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['saved'],
|
||||
setup(props, { emit }) {
|
||||
function emitSaved() {
|
||||
emit('saved', { ...props.book, rating: 4 })
|
||||
}
|
||||
return { emitSaved }
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<div data-test="details-rating">{{ book.rating }}</div>
|
||||
<button data-test="details-save" @click="emitSaved">save</button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
|
||||
const EditMetadataTabStub = defineComponent({
|
||||
name: 'EditMetadataTab',
|
||||
props: {
|
||||
book: {
|
||||
type: Object as PropType<BookDetail>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
template: '<div data-test="edit-rating">{{ book.rating }}</div>',
|
||||
})
|
||||
|
||||
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: '<div><slot /></div>' },
|
||||
DetailsTab: DetailsTabStub,
|
||||
EditMetadataTab: EditMetadataTabStub,
|
||||
FilesTab: { template: '<div />' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -60,6 +60,13 @@ export type BookDetailFile = {
|
||||
|
||||
export type ProviderIds = Partial<Record<MetadataProviderKey, string | null>>;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>).narrators).toBeUndefined();
|
||||
expect((result as Record<string, unknown>).duration).toBeUndefined();
|
||||
expect((result as Record<string, unknown>).abridged).toBeUndefined();
|
||||
expect((result as Record<string, unknown>).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();
|
||||
|
||||
@@ -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<string, unknown>).narrators;
|
||||
delete (previewResult as Record<string, unknown>).duration;
|
||||
delete (previewResult as Record<string, unknown>).abridged;
|
||||
delete (previewResult as Record<string, unknown>).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<typeof dto.audioMetadata.chapters>;
|
||||
}
|
||||
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
|
||||
? {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ async function errorsFor<T extends object>(cls: new () => T, value: Record<strin
|
||||
return validate(dto);
|
||||
}
|
||||
|
||||
async function strictErrorsFor<T extends object>(cls: new () => T, value: Record<string, unknown>) {
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user