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 => !('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(); 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 }); } }