feat: harden nested audio metadata contract and UI consistency

This commit is contained in:
neon
2026-03-29 10:04:26 -06:00
parent efbb63e764
commit d5ba57f483
16 changed files with 519 additions and 171 deletions
@@ -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')
})
})