mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
d0ce485bdc
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.
309 lines
11 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|