diff --git a/Dockerfile b/Dockerfile index 395f174f5..56667df04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,47 @@ FROM golang:1.26.2-alpine AS gosu-builder ARG GOSU_VERSION=1.19 RUN CGO_ENABLED=0 go install github.com/tianon/gosu@${GOSU_VERSION} +# Build whisper.cpp from source for local speech-to-text (ADR-0001 audio +# verification). Deliberately NOT the FFmpeg runtime-download pattern: upstream +# publishes no prebuilt Linux binaries (v1.8.6 ships only Windows zips and an +# Apple xcframework), and whisper.cpp is MIT, so baking it in at build time is +# both possible and simpler. Source build is arch-agnostic (amd64/arm64). +# GGML_NATIVE=OFF keeps the binary portable across CPUs of the same arch; +# GGML_OPENMP=OFF avoids a libgomp runtime dep the aspnet base image lacks. +# Instruction-set flags are pinned explicitly (x86-64-v3 baseline: AVX/AVX2/ +# FMA/F16C) rather than left to ggml's defaults, so the binary's requirements +# are visible here. A build with these ON dies with SIGILL (exit 132) on hosts +# capped at SSE4.2 (e.g. QEMU's default or x86-64-v2 CPU models) — flip them +# OFF if the image must run on such a host. AVX-512 stays OFF. +FROM debian:trixie-slim AS whisper-builder +ARG WHISPER_CPP_VERSION=v1.8.6 +ARG WHISPER_MODEL=base.en +RUN apt-get update \ + && apt-get install -y --no-install-recommends git build-essential cmake ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* +RUN git clone --depth 1 --branch ${WHISPER_CPP_VERSION} https://github.com/ggml-org/whisper.cpp /whisper \ + && cmake -S /whisper -B /whisper/build \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + -DGGML_NATIVE=OFF \ + -DGGML_OPENMP=OFF \ + -DGGML_AVX=ON \ + -DGGML_AVX2=ON \ + -DGGML_AVX512=OFF \ + -DGGML_AVX_VNNI=OFF \ + -DGGML_FMA=ON \ + -DGGML_F16C=ON \ + -DGGML_BMI2=ON \ + -DWHISPER_BUILD_TESTS=OFF \ + -DWHISPER_BUILD_SERVER=OFF \ + && cmake --build /whisper/build --config Release -j"$(nproc)" --target whisper-cli \ + && /whisper/models/download-ggml-model.sh ${WHISPER_MODEL} \ + && printf '%s\n' \ + "whisper.cpp ${WHISPER_CPP_VERSION} (MIT License) - https://github.com/ggml-org/whisper.cpp" \ + "ggml-${WHISPER_MODEL}.bin model from https://huggingface.co/ggerganov/whisper.cpp (MIT, derived from OpenAI Whisper weights)" \ + "See LICENSE-whisper.txt alongside this notice for the full license text." \ + > /whisper/NOTICE-whisper.txt + FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base WORKDIR /app EXPOSE 4545 @@ -51,6 +92,16 @@ RUN sh /tmp/listenarr-runtime/create-listenarr-user.sh COPY --from=build /app/publish . +# Bundled whisper.cpp CLI + model for audio verification (ADR-0001). +# WhisperService resolves these paths via ToolsRootPath (/app/tools/whisper) +# unless overridden with LISTENARR_WHISPER_BIN / LISTENARR_WHISPER_MODEL. +ARG WHISPER_MODEL=base.en +COPY --from=whisper-builder /whisper/build/bin/whisper-cli /app/tools/whisper/whisper-cli +COPY --from=whisper-builder /whisper/models/ggml-${WHISPER_MODEL}.bin /app/tools/whisper/ggml-${WHISPER_MODEL}.bin +COPY --from=whisper-builder /whisper/LICENSE /app/tools/whisper/LICENSE-whisper.txt +COPY --from=whisper-builder /whisper/NOTICE-whisper.txt /app/tools/whisper/NOTICE-whisper.txt +RUN chmod +x /app/tools/whisper/whisper-cli + # Install Node.js only for the Discord bot runtime. npm is used for the install # and then removed from the final filesystem; the bot only needs node. RUN sh /tmp/listenarr-runtime/install-discord-bot-runtime.sh diff --git a/fe/src/__tests__/AudiobookDetailView.spec.ts b/fe/src/__tests__/AudiobookDetailView.spec.ts index 6f3830f04..78e6176f6 100644 --- a/fe/src/__tests__/AudiobookDetailView.spec.ts +++ b/fe/src/__tests__/AudiobookDetailView.spec.ts @@ -50,6 +50,7 @@ vi.mock('@/services/signalr', () => ({ onDownloadUpdate: vi.fn(() => () => undefined), onDownloadsList: vi.fn(() => () => undefined), onScanJobUpdate: vi.fn(() => () => undefined), + onVerificationComplete: vi.fn(() => () => undefined), }, })) diff --git a/fe/src/__tests__/audiobook-detailview.signalr.spec.ts b/fe/src/__tests__/audiobook-detailview.signalr.spec.ts index 02786cbdf..c7058c8a6 100644 --- a/fe/src/__tests__/audiobook-detailview.signalr.spec.ts +++ b/fe/src/__tests__/audiobook-detailview.signalr.spec.ts @@ -53,6 +53,10 @@ describe('AudiobookDetailView SignalR integration', () => { void cb return () => {} } + ;(signalRService as unknown).onVerificationComplete = (cb?: (...args: unknown[]) => void) => { + void cb + return () => {} + } // Seed the library store with an initial audiobook const store = useLibraryStore() @@ -101,6 +105,10 @@ describe('AudiobookDetailView SignalR integration', () => { void cb return () => {} } + ;(signalRService as unknown).onVerificationComplete = (cb?: (...args: unknown[]) => void) => { + void cb + return () => {} + } callbacks[0](serverDto as Audiobook) diff --git a/fe/src/components/ui/FiltersDropdown.vue b/fe/src/components/ui/FiltersDropdown.vue index c5a6fa8af..41596c548 100644 --- a/fe/src/components/ui/FiltersDropdown.vue +++ b/fe/src/components/ui/FiltersDropdown.vue @@ -103,6 +103,8 @@ const builtInOptions = [ { value: 'unmonitored', label: 'Unmonitored Only' }, { value: 'missing', label: 'Missing' }, { value: 'recent', label: 'Recently Added' }, + { value: 'needs-review', label: 'Needs Review (verification)' }, + { value: 'no-spoken-credits', label: 'No Spoken Credits (verification)' }, ] const customFilters = computed(() => props.customFilters || []) diff --git a/fe/src/services/api.ts b/fe/src/services/api.ts index 91c699d90..c49fa3cc7 100644 --- a/fe/src/services/api.ts +++ b/fe/src/services/api.ts @@ -924,6 +924,56 @@ class ApiService { return this.request(`/rootfolders/${rootFolderId}/unmatched`) } + // Audio-based library verification (ADR-0001) + async startLibraryVerification(audiobookIds?: number[]): Promise<{ jobId: string }> { + return this.request<{ jobId: string }>('/verification/batch', { + method: 'POST', + body: JSON.stringify({ audiobookIds: audiobookIds ?? null }), + }) + } + + async verifyAudiobook(id: number): Promise<{ jobId: string }> { + return this.request<{ jobId: string }>(`/verification/audiobook/${id}`, { method: 'POST' }) + } + + async cancelVerificationJob(jobId: string): Promise<{ jobId: string; status: string }> { + return this.request<{ jobId: string; status: string }>(`/verification/jobs/${jobId}/cancel`, { + method: 'POST', + }) + } + + async setManualVerification( + id: number, + action: 'verify' | 'reject' | 'clear', + ): Promise<{ + id: number + verificationStatus: import('@/types').VerificationStatus + verificationConfidence?: number | null + verifiedAt?: string | null + verifiedBy?: string | null + verificationMethod?: string | null + }> { + return this.request(`/verification/audiobook/${id}/manual`, { + method: 'POST', + body: JSON.stringify({ action }), + }) + } + + async getVerificationJob(jobId: string): Promise<{ + jobId: string + status: string + total: number + processed: number + verified: number + flagged: number + unverifiable: number + skipped: number + failed: number + error?: string | null + }> { + return this.request(`/verification/jobs/${jobId}`) + } + // Discord integration helpers async getDiscordStatus(): Promise<{ success: boolean diff --git a/fe/src/services/signalr.ts b/fe/src/services/signalr.ts index f3049e5b0..4de578350 100644 --- a/fe/src/services/signalr.ts +++ b/fe/src/services/signalr.ts @@ -149,6 +149,34 @@ type ScanJobCallback = (job: { error?: string }) => void +// Audio verification (ADR-0001) progress events from the settings hub. +// trigger distinguishes user-started jobs ('manual') from auto-enqueued +// verify-on-import jobs ('import') so UIs can ignore background work. +export interface VerificationProgressPayload { + jobId: string + trigger?: string + processed: number + total: number + audiobookId: number + title?: string | null + outcome: string +} + +export interface VerificationCompletePayload { + jobId: string + trigger?: string + // Terminal job status: 'Completed' | 'Cancelled' | 'Failed' + status?: string + processed: number + total: number + verified: number + flagged: number + unverifiable?: number + skipped: number + failed: number + error?: string | null +} + class SignalRService { constructor() { // Reconnect when browser auth state changes so the hub handshake can pick @@ -190,6 +218,10 @@ class SignalRService { private queueUpdateCallbacks: Set = new Set() private audiobookUpdateCallbacks: Set<(a: Audiobook) => void> = new Set() private scanJobCallbacks: Set = new Set() + private verificationProgressCallbacks: Set<(payload: VerificationProgressPayload) => void> = + new Set() + private verificationCompleteCallbacks: Set<(payload: VerificationCompletePayload) => void> = + new Set() private moveJobCallbacks: Set< (job: { jobId: string @@ -384,6 +416,20 @@ class SignalRService { } switch (target) { + case 'VerificationProgress': + if (args && args[0]) { + const vpPayload = args[0] as VerificationProgressPayload + this.verificationProgressCallbacks.forEach((cb) => cb(vpPayload)) + } + break + + case 'VerificationComplete': + if (args && args[0]) { + const vcPayload = args[0] as VerificationCompletePayload + this.verificationCompleteCallbacks.forEach((cb) => cb(vcPayload)) + } + break + case 'DownloadUpdate': // Single or multiple download updates if (args && args[0]) { @@ -748,6 +794,21 @@ class SignalRService { // Subscribe to search progress messages (from server-side search operations) // By default clients do NOT receive automatic background search messages. To receive them, // pass `includeAutomatic=true`. + // Subscribe to audio-verification progress (per book) and completion + onVerificationProgress(callback: (payload: VerificationProgressPayload) => void): () => void { + this.verificationProgressCallbacks.add(callback) + return () => { + this.verificationProgressCallbacks.delete(callback) + } + } + + onVerificationComplete(callback: (payload: VerificationCompletePayload) => void): () => void { + this.verificationCompleteCallbacks.add(callback) + return () => { + this.verificationCompleteCallbacks.delete(callback) + } + } + onSearchProgress( callback: (payload: { message: string diff --git a/fe/src/types/index.ts b/fe/src/types/index.ts index 596251121..a2bfa9f6e 100644 --- a/fe/src/types/index.ts +++ b/fe/src/types/index.ts @@ -613,6 +613,54 @@ export interface AudiobookExternalIdentifierInput { export type AudiobookStatus = 'downloading' | 'no-file' | 'quality-mismatch' | 'quality-match' +// Audio-based identity verification (ADR-0001). Manual states are sticky: +// agent passes never overwrite manuallyVerified/rejected. agentUnverifiable is +// the neutral "audio never announces itself" state — not a flag. +export type VerificationStatus = + | 'unverified' + | 'agentVerified' + | 'agentFlagged' + | 'manuallyVerified' + | 'rejected' + | 'agentUnverifiable' + +export type VerificationOutcome = 'match' | 'mismatch' | 'uncertain' | 'noSpokenCredits' + +// Per-field verdict detail persisted by the agent pass (audiobook.verificationDetailJson). +export interface VerificationFieldMatch { + score: number + matchedText?: string | null +} + +// What the spoken credits CLAIM the book is, extracted from the opening +// transcript — seeds the "find correct match" relabel flow on flagged books. +export interface SpokenCredits { + title?: string | null + author?: string | null + narrator?: string | null + publisher?: string | null +} + +// On-disk audio length versus catalog runtime — distinguishes "partial content +// of the right book" from genuinely wrong audio. +export interface VerificationCompleteness { + expectedMinutes: number + actualMinutes: number + coverage: number +} + +export interface VerificationDetail { + outcome: VerificationOutcome + confidence: number + method: string + titleMatch?: VerificationFieldMatch | null + authorMatch?: VerificationFieldMatch | null + narratorMatch?: VerificationFieldMatch | null + publisherMatch?: VerificationFieldMatch | null + heardCredits?: SpokenCredits | null + completeness?: VerificationCompleteness | null +} + export interface Audiobook { id: number title: string @@ -657,6 +705,13 @@ export interface Audiobook { createdAt?: string source?: string }[] + verificationStatus?: VerificationStatus + verificationConfidence?: number | null + verifiedAt?: string | null + verifiedBy?: string | null + verificationMethod?: string | null + verificationTranscript?: string | null + verificationDetailJson?: string | null quality?: string qualityProfileId?: number // Optional list of author ASINs (populated by backend when available) diff --git a/fe/src/utils/verificationStatus.ts b/fe/src/utils/verificationStatus.ts new file mode 100644 index 000000000..d1d2f8a29 --- /dev/null +++ b/fe/src/utils/verificationStatus.ts @@ -0,0 +1,72 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import type { Audiobook, VerificationDetail, VerificationStatus } from '@/types' + +// Shared presentation mapping for the audio-verification badge (ADR-0001), +// used by the Books list and the book detail page so the two never disagree. + +export function verificationLabel(status: VerificationStatus | undefined): string { + switch (status) { + case 'agentVerified': + return 'Audio verified' + case 'agentFlagged': + return 'Needs review' + case 'manuallyVerified': + return 'Verified (manual)' + case 'rejected': + return 'Rejected' + case 'agentUnverifiable': + return 'No spoken credits' + default: + return 'Unverified' + } +} + +// CSS modifier class; the views provide the matching styles. +export function verificationClass(status: VerificationStatus | undefined): string { + switch (status) { + case 'agentVerified': + case 'manuallyVerified': + return 'verification-ok' + case 'agentFlagged': + return 'verification-flagged' + case 'rejected': + return 'verification-rejected' + case 'agentUnverifiable': + return 'verification-neutral' + default: + return 'verification-none' + } +} + +// "Needs review" = anything an agent flagged (mismatch OR uncertain — the +// detail JSON distinguishes which) that a human hasn't ruled on yet. +export function needsReview(book: Pick): boolean { + return book.verificationStatus === 'agentFlagged' +} + +export function parseVerificationDetail( + book: Pick, +): VerificationDetail | null { + if (!book.verificationDetailJson) return null + try { + return JSON.parse(book.verificationDetailJson) as VerificationDetail + } catch { + return null + } +} diff --git a/fe/src/views/library/AudiobookDetailView.vue b/fe/src/views/library/AudiobookDetailView.vue index b219b827e..8432a59d6 100644 --- a/fe/src/views/library/AudiobookDetailView.vue +++ b/fe/src/views/library/AudiobookDetailView.vue @@ -318,6 +318,110 @@ +
+

Audio Verification

+
+ Status: + + + {{ verificationLabel(audiobook.verificationStatus) }} + + + agent outcome: {{ verificationDetail.outcome }} + + +
+
+ Confidence: + {{ (audiobook.verificationConfidence * 100).toFixed(0) }}% +
+
+ Checked: + {{ formatDate(audiobook.verifiedAt) + }} +
+
+ Field matches: +
+
+ {{ row.label }} + {{ Math.round(row.score * 100) }}% + heard "{{ row.matchedText }}" +
+
+
+
+ Content length: + + {{ formatRuntime(verificationCompleteness.actualMinutes) }} of + {{ formatRuntime(verificationCompleteness.expectedMinutes) }} expected ({{ + Math.round(verificationCompleteness.coverage * 100) + }}%) + +
+
+ The audio says: +
+
+
+ Title + "{{ heardCredits.title }}" +
+
+ Author + "{{ heardCredits.author }}" +
+
+ Narrator + "{{ heardCredits.narrator }}" +
+
+ Publisher + "{{ heardCredits.publisher }}" +
+
+
+
+
+ + Transcript + + +
{{
+                audiobook.verificationTranscript
+              }}
+
+
+

Identifiers

@@ -666,6 +770,11 @@ import type { SearchResult, } from '@/types' import { safeText, stripHtmlAndNormalize } from '@/utils/textUtils' +import { + verificationLabel, + verificationClass, + parseVerificationDetail, +} from '@/utils/verificationStatus' import { logger } from '@/utils/logger' import { errorTracking } from '@/services/errorTracking' import { useProtectedImages } from '@/composables/useProtectedImages' @@ -683,6 +792,7 @@ import { PhSpinner, PhMagnifyingGlass, PhFolderOpen, + PhWaveform, PhTrash, PhClock, PhFolder, @@ -737,6 +847,83 @@ const scanQueued = ref(false) const scanJobId = ref(null) const showEditModal = ref(false) const showOrganizeModal = ref(false) + +// Audio verification (ADR-0001) state +const verifyingAudio = ref(false) +let verifyAudioJobId: string | null = null +const showTranscript = ref(false) + +const verificationDetail = computed(() => + audiobook.value ? parseVerificationDetail(audiobook.value) : null, +) + +// Per-field rows for the Audio Verification card, in matcher-weight order. +const verificationFieldScores = computed(() => { + const detail = verificationDetail.value + if (!detail) return [] + const fields = [ + { label: 'Title', match: detail.titleMatch }, + { label: 'Author', match: detail.authorMatch }, + { label: 'Narrator', match: detail.narratorMatch }, + { label: 'Publisher', match: detail.publisherMatch }, + ] + return fields + .filter((f) => f.match && typeof f.match.score === 'number') + .map((f) => ({ label: f.label, score: f.match!.score, matchedText: f.match!.matchedText })) +}) + +const heardCredits = computed(() => verificationDetail.value?.heardCredits ?? null) +const verificationCompleteness = computed(() => verificationDetail.value?.completeness ?? null) +const completenessIsShort = computed(() => (verificationCompleteness.value?.coverage ?? 1) < 0.7) +const completenessIsLong = computed(() => (verificationCompleteness.value?.coverage ?? 1) > 1.5) + +async function verifyAudio() { + const book = audiobook.value + if (!book || verifyingAudio.value) return + const toast = useToast() + verifyingAudio.value = true + try { + const result = await apiService.verifyAudiobook(book.id) + verifyAudioJobId = result.jobId + toast.info('Audio verification started', 'Transcribing the opening and closing of this book…') + } catch (err) { + verifyingAudio.value = false + const message = err instanceof Error ? err.message : 'Unknown error' + toast.error('Could not start audio verification', message) + } +} + +const verificationCompleteUnsub = signalRService.onVerificationComplete((payload) => { + if (!verifyAudioJobId || payload.jobId !== verifyAudioJobId) return + verifyAudioJobId = null + verifyingAudio.value = false + const toast = useToast() + void refresh() + if (payload.error) { + toast.error('Audio verification failed', payload.error) + } else if (payload.flagged > 0) { + toast.warning( + 'Audio verification complete', + 'This book was flagged — check the verification card for which field diverged.', + ) + } else if (payload.verified > 0) { + toast.success('Audio verification complete', 'The spoken credits match the stored metadata.') + } else if ((payload.unverifiable ?? 0) > 0) { + toast.info( + 'Audio verification complete', + 'No spoken credits found — the audio never announces itself, which is not evidence of wrong content.', + ) + } else { + toast.info( + 'Audio verification complete', + 'No verdict was recorded (book may have been skipped).', + ) + } +}) + +onUnmounted(() => { + verificationCompleteUnsub() +}) const showMoreActions = ref(false) // History state @@ -833,6 +1020,20 @@ const topActions = computed(() => [ showOrganizeModal.value = true }, }, + { + key: 'verify-audio', + label: verifyingAudio.value ? 'Verifying Audio...' : 'Verify Audio', + title: 'Transcribe the opening/closing of this book and check it against the metadata', + ariaLabel: 'Verify Audio', + icon: verifyingAudio.value ? PhSpinner : PhWaveform, + iconClass: verifyingAudio.value ? 'ph-spin' : undefined, + disabled: + verifyingAudio.value || (!audiobook.value?.files?.length && !audiobook.value?.filePath), + desktopGroup: 'secondary', + onClick: () => { + void verifyAudio() + }, + }, { key: 'delete', label: 'Delete', @@ -878,6 +1079,7 @@ type DetailTopAction = { | 'edit' | 'rescan-metadata' | 'organize' + | 'verify-audio' | 'delete' label: string title: string @@ -3262,4 +3464,195 @@ a.identifier-link:hover { padding-left: 10px; padding-right: 10px; } + +/* Audio Verification card (ADR-0001) */ +.verification-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.5rem; + border-radius: 6px; + font-size: 11px; + font-weight: 500; + background-color: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + color: #cfcfcf; +} + +.verification-badge.verification-ok { + background-color: rgba(46, 204, 113, 0.1); + border-color: rgba(46, 204, 113, 0.18); + color: #2ecc71; +} + +.verification-badge.verification-flagged { + background-color: rgba(243, 156, 18, 0.1); + border-color: rgba(243, 156, 18, 0.18); + color: #f39c12; +} + +.verification-badge.verification-rejected { + background-color: rgba(231, 76, 60, 0.12); + border-color: rgba(231, 76, 60, 0.18); + color: #e74c3c; +} + +.verification-outcome-note { + margin-left: 0.5rem; + font-size: 12px; + color: #8a93a0; +} + +.verification-fields { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.verification-field-row { + display: flex; + align-items: baseline; + gap: 0.6rem; + font-size: 13px; +} + +.verification-field-name { + min-width: 70px; + color: #adb5bd; +} + +.verification-field-score { + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.verification-field-heard { + color: #8a93a0; + font-style: italic; + overflow-wrap: anywhere; +} + +.verification-field-claim { + color: #d8dee6; + font-style: italic; + overflow-wrap: anywhere; +} + +.completeness-short { + color: #f39c12; +} + +.relabel-btn { + margin-top: 0.6rem; + align-self: flex-start; +} + +.split-collection-btn { + margin-top: 0; +} + +.verification-remedies { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +/* "The audio says" needs room: stack label, claims, and remedy buttons + vertically at full width instead of competing in one space-between row + (which squeezed the claims into a sliver that wrapped mid-word). */ +.verification-says-row { + flex-direction: column; + align-items: stretch; + gap: 8px; +} + +.verification-says { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; +} + +.verification-claims { + text-align: left; +} + +.verification-claims .verification-field-row { + justify-content: flex-start; +} + +.verification-claims .verification-field-name { + min-width: 80px; + flex-shrink: 0; +} + +.verification-claims .verification-field-claim { + flex: 1; +} + +.transcript-toggle { + margin-top: 0; + margin-left: 0.5rem; + padding: 2px 10px; + font-size: 11px; +} + +.verification-transcript { + margin: 0.5rem 0 0; + padding: 0.75rem; + max-height: 280px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + line-height: 1.5; + color: #c4cbd4; + background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 6px; +} + +.description :deep(p) { + margin: 0 0 12px 0; +} + +.description :deep(br) { + display: block; + margin: 8px 0; +} + +.description :deep(strong), +.description :deep(b) { + color: #fff; + font-weight: 500; +} + +.description :deep(em), +.description :deep(i) { + font-style: italic; +} + +.description :deep(a) { + color: var(--brand-500); +} + +.description :deep(a:hover) { + text-decoration: underline; +} + +.description :deep(ul), +.description :deep(ol) { + margin: 12px 0; + padding-left: 24px; +} + +.description :deep(li) { + margin: 4px 0; +} + +.verification-badge.verification-neutral { + background-color: rgba(134, 142, 150, 0.12); + border-color: rgba(134, 142, 150, 0.3); + color: #adb5bd; +} diff --git a/fe/src/views/library/AudiobooksView.vue b/fe/src/views/library/AudiobooksView.vue index 610575b98..5d3615700 100644 --- a/fe/src/views/library/AudiobooksView.vue +++ b/fe/src/views/library/AudiobooksView.vue @@ -456,6 +456,36 @@ @load="markImageLoaded(getBookImageKey(audiobook))" @error="handleLazyImageError(getBookImageKey(audiobook), $event)" /> + +
+ + +
{{ safeText(audiobook.title) }} @@ -640,6 +670,14 @@ {{ audiobook.monitored ? 'Monitored' : 'Unmonitored' }}
+
+ {{ verificationLabel(audiobook.verificationStatus) }} +