194 lines
7.8 KiB
TypeScript
194 lines
7.8 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 { getFlacMetadata, parseLabel } from '@/lib/utils/audio';
|
|
import { AnalysisTrack, AnalysisResponse } from '@/types';
|
|
|
|
const execFileAsync = util.promisify(execFile);
|
|
|
|
// This route analyzes the album: extracts metadata and checks quality
|
|
export async function POST(request: Request) {
|
|
try {
|
|
const { albumPath } = await request.json();
|
|
|
|
if (!albumPath || !(await fs.pathExists(albumPath))) {
|
|
return NextResponse.json({ error: 'Invalid album path' }, { status: 400 });
|
|
}
|
|
|
|
const files = await fs.readdir(albumPath);
|
|
const flacFiles = files.filter((file) => file.toLowerCase().endsWith('.flac'));
|
|
|
|
if (flacFiles.length === 0) {
|
|
return NextResponse.json({ error: 'No FLAC files found' }, { status: 400 });
|
|
}
|
|
|
|
const tracks: (AnalysisTrack & { size?: number })[] = await Promise.all(
|
|
flacFiles.map(async (file) => {
|
|
const filePath = path.join(albumPath, file);
|
|
try {
|
|
// Check for audio corruption
|
|
try {
|
|
await execFileAsync('ffmpeg', ['-v', 'error', '-i', filePath, '-f', 'null', '-']);
|
|
} catch (ffmpegError) {
|
|
return {
|
|
filename: file,
|
|
path: filePath,
|
|
error: 'Corrupted audio data or length metadata mismatch',
|
|
};
|
|
}
|
|
|
|
const stats = await fs.stat(filePath);
|
|
const metadata = await getFlacMetadata(filePath);
|
|
return {
|
|
filename: file,
|
|
path: filePath,
|
|
...metadata,
|
|
fullPathLength: filePath.length,
|
|
size: stats.size,
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
filename: file,
|
|
path: filePath,
|
|
error: 'Failed to parse metadata',
|
|
};
|
|
}
|
|
})
|
|
);
|
|
|
|
// Calculate total size
|
|
const totalSize = tracks.reduce((acc, track) => acc + (track.size || 0), 0);
|
|
|
|
// Sort tracks by track number if available, else by filename
|
|
tracks.sort((a, b) => {
|
|
if ('error' in a || 'error' in b) return 0; // Keep order if error
|
|
const trackA = a.common?.track?.no ?? 0;
|
|
const trackB = b.common?.track?.no ?? 0;
|
|
if (trackA && trackB) return trackA - trackB;
|
|
return a.filename.localeCompare(b.filename);
|
|
});
|
|
|
|
// Analyze quality
|
|
let isLossless = true;
|
|
let isHiRes = true;
|
|
let mixed = false;
|
|
let qualityDetails = '';
|
|
|
|
const sampleRates = new Set();
|
|
const bitDepths = new Set();
|
|
|
|
tracks.forEach((track) => {
|
|
if ('error' in track) return;
|
|
const sr = track.format?.sampleRate;
|
|
const bd = track.format?.bitsPerSample;
|
|
|
|
if (sr) sampleRates.add(sr);
|
|
if (bd) bitDepths.add(bd);
|
|
|
|
if (sr !== 44100 || bd !== 16) {
|
|
isLossless = false; // Not strictly CD quality
|
|
}
|
|
if (!sr || sr < 48000 || !bd || bd < 24) {
|
|
isHiRes = false; // Not strictly Hi-Res
|
|
}
|
|
});
|
|
|
|
if (sampleRates.size > 1 || bitDepths.size > 1) {
|
|
mixed = true;
|
|
qualityDetails = `NOTE: Mixed formats. Sample Rates: ${Array.from(sampleRates).join(', ')} Hz. Bit Depths: ${Array.from(bitDepths).join(', ')} bit.`;
|
|
|
|
// Generate detailed message for mixed content
|
|
const mixedDetails = tracks.map(t => {
|
|
if ('error' in t) return `${t.filename}: Error`;
|
|
return `Track ${t.common?.track?.no || '?'}: ${t.format?.bitsPerSample}bit / ${t.format?.sampleRate}Hz`;
|
|
}).join('\n');
|
|
|
|
qualityDetails += '\n' + mixedDetails;
|
|
}
|
|
|
|
// Determine overall status
|
|
let status = 'UNKNOWN';
|
|
if (tracks.some(t => 'error' in t)) {
|
|
status = 'CORRUPT';
|
|
} else if (mixed) {
|
|
status = 'MIXED';
|
|
} else if (isHiRes) {
|
|
status = 'HI-RES'; // 24bit LOSSLESS
|
|
} else if (sampleRates.has(44100) && bitDepths.has(16) && sampleRates.size === 1 && bitDepths.size === 1) {
|
|
status = 'LOSSLESS';
|
|
} else {
|
|
// Could be 48/16 or something else
|
|
status = `OTHER (${Array.from(bitDepths)[0]}bit / ${Array.from(sampleRates)[0]}Hz)`;
|
|
}
|
|
|
|
// Extract Album Metadata (from first track)
|
|
const firstValidTrack = tracks.find((t): t is Extract<AnalysisTrack, { error?: never }> => !('error' in t));
|
|
|
|
let label = parseLabel(firstValidTrack?.common?.copyright || 'Unknown Label');
|
|
if (!label) label = 'Unknown Label'; // Fallback if parsing resulted in empty string (e.g. Records DK)
|
|
|
|
// Additional artists: filter out album artist from artists list
|
|
const albumArtist = firstValidTrack?.common?.albumartist || firstValidTrack?.common?.artist || '';
|
|
// Collect artists from ALL tracks, not just the first one
|
|
const allArtistsSet = new Set<string>();
|
|
tracks.forEach(t => {
|
|
if ('error' in t) return;
|
|
const trackArtists = t.common?.artists || [];
|
|
trackArtists.forEach(artist => {
|
|
// Split by common delimiters to handle combined strings like "A, B & C"
|
|
const parts = artist.split(/,\s+|&|\s+\/\s+/).map(a => a.trim()).filter(a => a.length > 0);
|
|
parts.forEach(p => allArtistsSet.add(p));
|
|
});
|
|
});
|
|
const allArtists = Array.from(allArtistsSet);
|
|
|
|
// Identify all main artists (Album Artist + Track Artist) individually
|
|
const mainArtistNames = new Set(
|
|
[albumArtist, firstValidTrack?.common?.artist || '']
|
|
.flatMap(name => name.split(/,\s+|&|\s+\/\s+/)) // Split by comma, &, /
|
|
.map(a => a.trim())
|
|
.filter(a => a.length > 0)
|
|
);
|
|
|
|
const additionalArtists = allArtists.filter(a => !mainArtistNames.has(a));
|
|
|
|
const albumMetadata = {
|
|
artist: firstValidTrack?.common?.artist || 'Unknown Artist',
|
|
album: firstValidTrack?.common?.album || 'Unknown Album',
|
|
year: firstValidTrack?.common?.year || (firstValidTrack?.common?.date ? parseInt(firstValidTrack.common.date.split('-')[0]) : undefined),
|
|
upc: firstValidTrack?.common?.barcode,
|
|
label: label,
|
|
tidalUrl: firstValidTrack?.native?.tidalUrl,
|
|
additionalArtists: additionalArtists.length > 0 ? additionalArtists : undefined,
|
|
releaseType: (() => {
|
|
const name = firstValidTrack?.common?.album?.toLowerCase() || '';
|
|
if (name.includes('live')) return 11; // Live album
|
|
if (name.includes('soundtrack') || name.includes('ost')) return 3; // Soundtrack
|
|
|
|
const validTracks = tracks.filter((t) => !('error' in t)).length;
|
|
if (validTracks === 1) return 9; // Single
|
|
if (validTracks <= 5) return 5; // EP
|
|
|
|
return 1; // Album
|
|
})(),
|
|
totalSize
|
|
};
|
|
|
|
return NextResponse.json({
|
|
albumPath,
|
|
metadata: albumMetadata,
|
|
tracks,
|
|
quality: {
|
|
status,
|
|
mixed,
|
|
details: qualityDetails,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('Error analyzing album:', error);
|
|
return NextResponse.json({ error: 'Failed to analyze album' }, { status: 500 });
|
|
}
|
|
}
|