mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
8a757f5b67
Add support for selecting individual audio files during manual and bulk imports and pass that selection through the scan, API, job queue, processor and organizer. Key changes: - API: scan now returns audioFiles for each discovered book and emits a new 'grouping' progress phase; execute and manual-import routes accept file lists (audioFiles / selectedFiles) and validate them. - Scanner: group loose audio files by metadata (title/author/narrator), deduplicate multi-part sets (CD1/CD2) across folders, and return audioFiles + groupingKey; add concurrency limit for ffprobe reads and merge groups post-scan. - Job queue & processor: OrganizeFiles payload now includes selectedFiles; processors forward selectedFiles to the FileOrganizer and to cleanup logic. - File organizer & cleanup: filter to only selectedFiles when organizing; cleanup now deletes only the selected files (if provided) instead of removing the whole directory. - UI: Manual import browser and bulk import wizard updated to show per-file selection, track checkedFiles, toggle all, and send selected files to the API; ConfirmPhase updated to allow checking/unchecking files and prevents starting import with no files selected. - Filesystem browse: removed expensive per-subfolder stats to keep browsing responsive (now lists subdirectories without nested stat calls). Overall this change enables finer-grained imports, reduces accidental deletion of unselected files, and improves scan grouping for multi-folder audiobooks.
403 lines
15 KiB
TypeScript
403 lines
15 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, selectedFiles } = body;
|
|
let { audiobookId } = body;
|
|
|
|
// Validate selectedFiles if provided
|
|
if (selectedFiles !== undefined) {
|
|
if (!Array.isArray(selectedFiles) || selectedFiles.length === 0) {
|
|
return NextResponse.json(
|
|
{ error: 'selectedFiles must be a non-empty array of file names' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
if (!selectedFiles.every((f: unknown) => typeof f === 'string')) {
|
|
return NextResponse.json(
|
|
{ error: 'selectedFiles must contain only strings' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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 selected files exist and are audio files, or fall back to folder scan
|
|
let audioFileCount: number;
|
|
const validatedFiles: string[] = [];
|
|
|
|
if (selectedFiles && selectedFiles.length > 0) {
|
|
for (const fileName of selectedFiles as string[]) {
|
|
// Prevent path traversal
|
|
if (fileName.includes('/') || fileName.includes('\\') || fileName === '..' || fileName === '.') {
|
|
return NextResponse.json(
|
|
{ error: `Invalid file name: ${fileName}` },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
const ext = pathModule.extname(fileName).toLowerCase();
|
|
if (!(AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
|
|
return NextResponse.json(
|
|
{ error: `Not an audio file: ${fileName}` },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
try {
|
|
const fileStat = await fs.stat(pathModule.join(normalizedPath, fileName));
|
|
if (!fileStat.isFile()) {
|
|
return NextResponse.json(
|
|
{ error: `Not a file: ${fileName}` },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
validatedFiles.push(fileName);
|
|
} catch {
|
|
return NextResponse.json(
|
|
{ error: `File not found: ${fileName}` },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
}
|
|
audioFileCount = validatedFiles.length;
|
|
} else {
|
|
const audioCheck = await hasAudioFiles(normalizedPath);
|
|
if (!audioCheck.found) {
|
|
return NextResponse.json(
|
|
{ error: 'No audio files found in the selected directory' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
audioFileCount = audioCheck.count;
|
|
}
|
|
|
|
// 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,
|
|
validatedFiles.length > 0 ? validatedFiles : undefined
|
|
);
|
|
|
|
logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioFileCount}`);
|
|
|
|
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 }
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|