mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
09e1a0db3a
Replace default Anna's Archive base URL from https://annas-archive.li to https://annas-archive.gl across docs, UI components, API routes, processors, services, and tests. Add comprehensive tests for the admin manual-import API route and enhance the manual-import route to fetch missing ASIN details from Audnexus and create audiobook records with proper error handling and logging. Update related test expectations and FlareSolverr test usages to reflect the new default URL.
341 lines
12 KiB
TypeScript
341 lines
12 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 {
|
|
// Not in DB — fetch live from Audnexus and create a record
|
|
try {
|
|
const audibleService = getAudibleService();
|
|
const liveData = await audibleService.getAudiobookDetails(asin);
|
|
if (liveData) {
|
|
const newBook = await prisma.audiobook.create({
|
|
data: {
|
|
audibleAsin: asin,
|
|
title: liveData.title,
|
|
author: liveData.author,
|
|
coverArtUrl: liveData.coverArtUrl,
|
|
narrator: liveData.narrator,
|
|
series: liveData.series,
|
|
seriesPart: liveData.seriesPart,
|
|
seriesAsin: liveData.seriesAsin,
|
|
year: liveData.releaseDate
|
|
? new Date(liveData.releaseDate).getFullYear() || undefined
|
|
: undefined,
|
|
status: 'pending',
|
|
},
|
|
});
|
|
audiobookId = newBook.id;
|
|
logger.info(`Created audiobook record from Audnexus for ASIN ${asin}: ${newBook.id}`);
|
|
} else {
|
|
return NextResponse.json(
|
|
{ error: 'Audiobook not found for the given ASIN' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
} catch (audnexusError) {
|
|
logger.error(`Failed to fetch ASIN ${asin} from Audnexus: ${audnexusError instanceof Error ? audnexusError.message : String(audnexusError)}`);
|
|
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 }
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|