Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions fe/src/__tests__/AudiobookDetailView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ vi.mock('@/services/signalr', () => ({
onDownloadUpdate: vi.fn(() => () => undefined),
onDownloadsList: vi.fn(() => () => undefined),
onScanJobUpdate: vi.fn(() => () => undefined),
onVerificationComplete: vi.fn(() => () => undefined),
},
}))

Expand Down
8 changes: 8 additions & 0 deletions fe/src/__tests__/audiobook-detailview.signalr.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions fe/src/components/ui/FiltersDropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [])
Expand Down
50 changes: 50 additions & 0 deletions fe/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,56 @@ class ApiService {
return this.request<SavedUnmatchedResponse>(`/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
Expand Down
61 changes: 61 additions & 0 deletions fe/src/services/signalr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -190,6 +218,10 @@ class SignalRService {
private queueUpdateCallbacks: Set<QueueUpdateCallback> = new Set()
private audiobookUpdateCallbacks: Set<(a: Audiobook) => void> = new Set()
private scanJobCallbacks: Set<ScanJobCallback> = new Set()
private verificationProgressCallbacks: Set<(payload: VerificationProgressPayload) => void> =
new Set()
private verificationCompleteCallbacks: Set<(payload: VerificationCompletePayload) => void> =
new Set()
private moveJobCallbacks: Set<
(job: {
jobId: string
Expand Down Expand Up @@ -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]) {
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions fe/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions fe/src/utils/verificationStatus.ts
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Audiobook, 'verificationStatus'>): boolean {
return book.verificationStatus === 'agentFlagged'
}

export function parseVerificationDetail(
book: Pick<Audiobook, 'verificationDetailJson'>,
): VerificationDetail | null {
if (!book.verificationDetailJson) return null
try {
return JSON.parse(book.verificationDetailJson) as VerificationDetail
} catch {
return null
}
}
Loading