Files
ReadMeABook/src/app/api/admin/manual-import/route.ts
T
kikootwo d0ce485bdc Enrich audiobook metadata from Audnexus
Query Audnexus (Audible) to backfill missing metadata during manual imports and file organization. Adds getAudibleService imports and calls to fetch audiobook details by ASIN, then backfills series, seriesPart, seriesAsin, year (from releaseDate) and narrator when missing and updates the DB. Failures are non-fatal and logged; logs were added to surface enrichment steps. Also uses the resolved series/seriesPart when building organization metadata.
2026-03-04 12:19:37 -05:00

309 lines
11 KiB
TypeScript

/**
* Component: Admin Manual Import API
* Documentation: documentation/features/manual-import.md
*
* Triggers the organize_files pipeline for a manually-selected folder.
* Creates or recycles a request, then queues the organize job.
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { RMABLogger } from '@/lib/utils/logger';
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
import { getAudibleService } from '@/lib/integrations/audible.service';
const logger = RMABLogger.create('API.Admin.ManualImport');
/** Statuses that indicate the request is actively being worked on. */
const ACTIVE_STATUSES = ['searching', 'downloading', 'processing', 'awaiting_import'];
/** Statuses that can be recycled for a new manual import. */
const RECYCLABLE_STATUSES = ['failed', 'warn', 'cancelled', 'denied', 'pending', 'awaiting_search', 'awaiting_approval'];
/**
* Check if a directory contains at least one audio file (immediate children only).
*/
async function hasAudioFiles(dirPath: string): Promise<{ found: boolean; count: number }> {
const fs = await import('fs/promises');
const pathModule = await import('path');
let count = 0;
try {
const children = await fs.readdir(dirPath, { withFileTypes: true });
for (const child of children) {
if (child.isFile()) {
const ext = pathModule.extname(child.name).toLowerCase();
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
count++;
}
}
}
} catch {
/* directory not readable */
}
return { found: count > 0, count };
}
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const pathModule = await import('path');
const fs = await import('fs/promises');
const body = await request.json();
const { folderPath, asin, cleanupSource } = body;
let { audiobookId } = body;
// Validate required fields
if ((!audiobookId && !asin) || !folderPath) {
return NextResponse.json(
{ error: 'folderPath and either audiobookId or asin are required' },
{ status: 400 }
);
}
// Load allowed roots
const BOOKDROP_PATH = '/bookdrop';
const downloadDirConfig = await prisma.configuration.findUnique({
where: { key: 'download_dir' },
});
const mediaDirConfig = await prisma.configuration.findUnique({
where: { key: 'media_dir' },
});
const allowedRoots: string[] = [];
if (downloadDirConfig?.value) {
allowedRoots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
}
if (mediaDirConfig?.value) {
allowedRoots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
}
try {
const bookdropStat = await fs.stat(BOOKDROP_PATH);
if (bookdropStat.isDirectory()) {
allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
}
} catch {
/* not mounted */
}
// Normalize and validate path
const normalizedPath = pathModule.resolve(folderPath).replace(/\\/g, '/');
const isAllowed = allowedRoots.some(
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
);
if (!isAllowed) {
return NextResponse.json(
{ error: 'Access denied: path outside allowed directories' },
{ status: 403 }
);
}
// Verify folder exists and is a directory
try {
const stat = await fs.stat(normalizedPath);
if (!stat.isDirectory()) {
return NextResponse.json(
{ error: 'Path is not a directory' },
{ status: 400 }
);
}
} catch {
return NextResponse.json(
{ error: 'Directory not found' },
{ status: 404 }
);
}
// Verify folder contains audio files
const audioCheck = await hasAudioFiles(normalizedPath);
if (!audioCheck.found) {
return NextResponse.json(
{ error: 'No audio files found in the selected directory' },
{ status: 400 }
);
}
// Resolve audiobook by ASIN if audiobookId not provided
if (!audiobookId && asin) {
const byAsin = await prisma.audiobook.findFirst({
where: { audibleAsin: asin },
});
if (byAsin) {
audiobookId = byAsin.id;
} else {
// Create audiobook record from Audible cache if available
const cached = await prisma.audibleCache.findUnique({
where: { asin },
});
if (cached) {
const newBook = await prisma.audiobook.create({
data: {
audibleAsin: asin,
title: cached.title,
author: cached.author,
coverArtUrl: cached.coverArtUrl,
narrator: cached.narrator,
status: 'pending',
},
});
audiobookId = newBook.id;
logger.info(`Created audiobook record from cache for ASIN ${asin}: ${newBook.id}`);
} else {
return NextResponse.json(
{ error: 'Audiobook not found for the given ASIN' },
{ status: 404 }
);
}
}
}
// Verify audiobook exists
const audiobook = await prisma.audiobook.findUnique({
where: { id: audiobookId },
});
if (!audiobook) {
return NextResponse.json(
{ error: 'Audiobook not found' },
{ status: 404 }
);
}
// Enrich missing series/year data from Audnexus (mirrors request-creator.service.ts)
if (audiobook.audibleAsin && (!audiobook.series || !audiobook.year)) {
try {
const audibleService = getAudibleService();
const audnexusData = await audibleService.getAudiobookDetails(audiobook.audibleAsin);
if (audnexusData) {
const updates: Record<string, any> = {};
if (!audiobook.series && audnexusData.series) {
updates.series = audnexusData.series;
}
if (!audiobook.seriesPart && audnexusData.seriesPart) {
updates.seriesPart = audnexusData.seriesPart;
}
if (!audiobook.seriesAsin && audnexusData.seriesAsin) {
updates.seriesAsin = audnexusData.seriesAsin;
}
if (!audiobook.year && audnexusData.releaseDate) {
const releaseYear = new Date(audnexusData.releaseDate).getFullYear();
if (!isNaN(releaseYear)) {
updates.year = releaseYear;
}
}
if (!audiobook.narrator && audnexusData.narrator) {
updates.narrator = audnexusData.narrator;
}
if (Object.keys(updates).length > 0) {
await prisma.audiobook.update({
where: { id: audiobook.id },
data: updates,
});
logger.info(`Enriched audiobook metadata from Audnexus for ASIN ${audiobook.audibleAsin}`, updates);
}
}
} catch (error) {
// Non-fatal: series enrichment failure should never block the import
logger.warn(`Failed to enrich metadata from Audnexus for ASIN ${audiobook.audibleAsin}: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Check for existing requests
const existingRequest = await prisma.request.findFirst({
where: {
audiobookId,
type: 'audiobook',
deletedAt: null,
},
orderBy: { createdAt: 'desc' },
});
let requestId: string;
if (existingRequest) {
// Check if already in an active state
if (ACTIVE_STATUSES.includes(existingRequest.status)) {
return NextResponse.json(
{ error: 'This audiobook is already being processed' },
{ status: 409 }
);
}
// Recycle the existing request
if (RECYCLABLE_STATUSES.includes(existingRequest.status) ||
existingRequest.status === 'downloaded' ||
existingRequest.status === 'available') {
await prisma.request.update({
where: { id: existingRequest.id },
data: {
status: 'processing',
progress: 100,
errorMessage: null,
importAttempts: 0,
updatedAt: new Date(),
},
});
requestId = existingRequest.id;
logger.info(`Recycled existing request ${requestId} for manual import`);
} else {
// Unknown status - create new
const newRequest = await prisma.request.create({
data: {
userId: req.user!.id,
audiobookId,
type: 'audiobook',
status: 'processing',
progress: 100,
},
});
requestId = newRequest.id;
logger.info(`Created new request ${requestId} (existing had status: ${existingRequest.status})`);
}
} else {
// No existing request - create one
const newRequest = await prisma.request.create({
data: {
userId: req.user!.id,
audiobookId,
type: 'audiobook',
status: 'processing',
progress: 100,
},
});
requestId = newRequest.id;
logger.info(`Created new request ${requestId} for manual import`);
}
// Queue organize_files job
const jobQueue = getJobQueueService();
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath, undefined, cleanupSource === true);
logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioCheck.count}`);
return NextResponse.json({
success: true,
requestId,
message: `Import started for ${audiobook.title}`,
});
} catch (error) {
logger.error('Manual import failed', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Manual import failed' },
{ status: 500 }
);
}
});
});
}