Files
crossfade/app/api/spectrogram/route.ts
T

132 lines
4.6 KiB
TypeScript

import { NextResponse } from 'next/server';
import fs from 'fs-extra';
import path from 'path';
import { execFile } from 'child_process';
import util from 'util';
import { getSpectrogramsPath, ensureSpectrogramsDir } from '@/lib/utils/paths';
import { AnalysisTrack, SpectrogramResponse } from '@/types';
const execFileAsync = util.promisify(execFile);
export async function POST(request: Request) {
try {
const { albumPath, tracks, upc } = await request.json();
if (!albumPath || !tracks || !Array.isArray(tracks)) {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
const specRoot = await ensureSpectrogramsDir();
// Use UPC if available, otherwise fallback to album directory name
const albumId = upc || path.basename(albumPath);
const albumSpecDir = path.join(specRoot, albumId);
await fs.ensureDir(albumSpecDir);
const results: Record<string, { exists: boolean, url?: string }> = {};
// Process tracks
// We use Promise.all to process in parallel.
// If this causes performance issues with sox, we might need to limit concurrency.
await Promise.all(tracks.map(async (track: AnalysisTrack) => {
if ('error' in track) return;
const trackNo = track.common?.track?.no;
const discNo = track.common?.disk?.no;
if (trackNo === undefined || trackNo === null) return;
const discPrefix = (discNo && discNo > 1) || tracks.some(t => !('error' in t) && (t.common?.disk?.no ?? 1) > 1)
? `${discNo ?? 1}-`
: '';
const outputFilename = `${discPrefix}${trackNo}.png`;
const outputPath = path.join(albumSpecDir, outputFilename);
const relativePath = path.join(albumId, outputFilename);
const publicUrl = `/api/spectrogram?path=${encodeURIComponent(relativePath)}`;
const exists = await fs.pathExists(outputPath);
if (!exists) {
// Generate
const title = `${discPrefix}${trackNo} - ${track.common?.title || track.filename}`;
const args = [
track.path,
'-n',
];
if (process.env.GENERATE_MONO_SPECTROGRAMS) {
args.push('remix', '1');
}
args.push(
'spectrogram',
'-t', title,
'-o', outputPath
);
try {
await execFileAsync('sox', args);
results[track.filename] = { exists: true, url: publicUrl };
} catch (e) {
console.error(`Failed to generate spectrogram for ${track.filename}:`, e);
results[track.filename] = { exists: false };
}
} else {
results[track.filename] = { exists: true, url: publicUrl };
}
}));
return NextResponse.json({ results });
} catch (error) {
console.error('Error generating spectrograms:', error);
return NextResponse.json({ error: 'Failed to generate spectrograms' }, { status: 500 });
}
}
export async function DELETE() {
try {
const specPath = getSpectrogramsPath();
await fs.remove(specPath);
await fs.ensureDir(specPath);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error purging spectrograms:', error);
return NextResponse.json({ error: 'Failed to purge spectrograms' }, { status: 500 });
}
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const relativePath = searchParams.get('path');
if (!relativePath) {
return NextResponse.json({ error: 'Missing path' }, { status: 400 });
}
const specRoot = getSpectrogramsPath();
const fullPath = path.join(specRoot, relativePath);
// Security check: ensure fullPath is within specRoot (prevent directory traversal)
const resolvedPath = path.resolve(fullPath);
const resolvedRoot = path.resolve(specRoot);
if (!resolvedPath.startsWith(resolvedRoot)) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 });
}
if (!await fs.pathExists(resolvedPath)) {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
const fileBuffer = await fs.readFile(resolvedPath);
return new NextResponse(fileBuffer, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}