Restart repository history from target event
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs-extra';
|
||||
import mime from 'mime';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const filePath = searchParams.get('path');
|
||||
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: 'Path is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const stat = await fs.stat(filePath);
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: 'Not a file' }, { status: 400 });
|
||||
}
|
||||
|
||||
const fileSize = stat.size;
|
||||
const range = request.headers.get('range');
|
||||
const contentType = mime.getType(filePath) || 'application/octet-stream';
|
||||
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, "").split("-");
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
const chunksize = (end - start) + 1;
|
||||
const file = fs.createReadStream(filePath, { start, end });
|
||||
|
||||
// Convert stream to ReadableStream for NextResponse
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
file.on('data', (chunk) => {
|
||||
try {
|
||||
controller.enqueue(chunk);
|
||||
} catch (e) {
|
||||
// Controller likely closed, stop reading
|
||||
file.destroy();
|
||||
}
|
||||
});
|
||||
file.on('end', () => {
|
||||
try {
|
||||
controller.close();
|
||||
} catch (e) { }
|
||||
});
|
||||
file.on('error', (err) => {
|
||||
try {
|
||||
controller.error(err);
|
||||
} catch (e) { }
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
file.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
return new NextResponse(stream, {
|
||||
status: 206,
|
||||
headers: {
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunksize.toString(),
|
||||
'Content-Type': contentType,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const file = fs.createReadStream(filePath);
|
||||
|
||||
// Convert stream to ReadableStream for NextResponse
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
file.on('data', (chunk) => {
|
||||
try {
|
||||
controller.enqueue(chunk);
|
||||
} catch (e) {
|
||||
file.destroy();
|
||||
}
|
||||
});
|
||||
file.on('end', () => {
|
||||
try {
|
||||
controller.close();
|
||||
} catch (e) { }
|
||||
});
|
||||
file.on('error', (err) => {
|
||||
try {
|
||||
controller.error(err);
|
||||
} catch (e) { }
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
file.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
'Content-Length': fileSize.toString(),
|
||||
'Content-Type': contentType,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error serving audio file:', error);
|
||||
return NextResponse.json({ error: 'Failed to serve file' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { BatchRenameResponse, RenameOperation, RenameResult } from '@/types';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { albumPath, renames } = await request.json();
|
||||
|
||||
if (!albumPath || !(await fs.pathExists(albumPath))) {
|
||||
return NextResponse.json({ error: 'Invalid album path' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!renames || !Array.isArray(renames)) {
|
||||
return NextResponse.json({ error: 'Invalid renames list' }, { status: 400 });
|
||||
}
|
||||
|
||||
const results: RenameResult[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const { original, newName } of renames as RenameOperation[]) {
|
||||
try {
|
||||
const oldPath = path.join(albumPath, original);
|
||||
const newPath = path.join(albumPath, newName);
|
||||
|
||||
if (await fs.pathExists(oldPath)) {
|
||||
await fs.rename(oldPath, newPath);
|
||||
results.push({ original, newName, success: true });
|
||||
} else {
|
||||
errors.push(`File not found: ${original}`);
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
errors.push(`Failed to rename ${original}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
const response: BatchRenameResponse = {
|
||||
success: errors.length === 0,
|
||||
results,
|
||||
errors: errors.length > 0 ? errors : undefined
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error executing renames:', error);
|
||||
return NextResponse.json({ error: 'Failed to execute renames' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { exec } from 'child_process';
|
||||
import util from 'util';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { CheckMQAResponse } from '@/types';
|
||||
|
||||
const execPromise = util.promisify(exec);
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// Find all FLAC files
|
||||
const files = await fs.readdir(albumPath);
|
||||
const flacFiles = files.filter(f => f.toLowerCase().endsWith('.flac'));
|
||||
|
||||
if (flacFiles.length === 0) {
|
||||
return NextResponse.json({ error: 'No FLAC files found' }, { status: 400 });
|
||||
}
|
||||
|
||||
let mqaDetected = false;
|
||||
let outputLog = "";
|
||||
|
||||
// Check all files
|
||||
for (const file of flacFiles) {
|
||||
const filePath = path.join(albumPath, file);
|
||||
const command = `salmon check mqa "${filePath}"`;
|
||||
|
||||
try {
|
||||
const { stdout } = await execPromise(command);
|
||||
const isMQA = !stdout.includes('Did not find MQA syncword');
|
||||
|
||||
outputLog += `[${file}]: ${isMQA ? "MQA DETECTED" : "Clean"}\n`;
|
||||
|
||||
if (isMQA) {
|
||||
mqaDetected = true;
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
outputLog += `[${file}]: Error - ${errorMessage}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const response: CheckMQAResponse = {
|
||||
success: true,
|
||||
isMQA: mqaDetected,
|
||||
output: outputLog
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking MQA:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { exec } from 'child_process';
|
||||
import util from 'util';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import * as mm from 'music-metadata';
|
||||
import { CheckUpconvResponse } from '@/types';
|
||||
|
||||
const execPromise = util.promisify(exec);
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// Find all FLAC files
|
||||
const files = await fs.readdir(albumPath);
|
||||
const flacFiles = files.filter(f => f.toLowerCase().endsWith('.flac'));
|
||||
|
||||
if (flacFiles.length === 0) {
|
||||
return NextResponse.json({ error: 'No FLAC files found' }, { status: 400 });
|
||||
}
|
||||
|
||||
let outputLog = "";
|
||||
let failureCount = 0;
|
||||
let checkedCount = 0;
|
||||
|
||||
// Check all 24-bit files
|
||||
for (const file of flacFiles) {
|
||||
const filePath = path.join(albumPath, file);
|
||||
|
||||
try {
|
||||
// Check bit depth first
|
||||
const metadata = await mm.parseFile(filePath);
|
||||
const bitsPerSample = metadata.format.bitsPerSample || 16;
|
||||
|
||||
if (bitsPerSample < 24) {
|
||||
continue; // Skip non-24-bit files
|
||||
}
|
||||
|
||||
checkedCount++;
|
||||
const command = `salmon check upconv "${filePath}"`;
|
||||
const { stdout } = await execPromise(command);
|
||||
|
||||
// Output example: "01...flac: does not have a high number of wasted bits (Wasted bits: 0/24)"
|
||||
const match = stdout.match(/Wasted bits: (\d+\/\d+)/);
|
||||
const wastedBits = match ? match[1] : 'Unknown';
|
||||
|
||||
// Determine pass/fail based on wasted bits (simplified logic: if wasted bits > 0, it might be upconv, but usually salmon says "has a high number..." if it fails)
|
||||
// Actually salmon output usually says "does not have a high number..." for pass.
|
||||
// Let's rely on the text "does not have a high number".
|
||||
const passed = stdout.includes('does not have a high number');
|
||||
|
||||
outputLog += `[${file}]: ${passed ? "OK" : "POSSIBLE UPCONV"} (${wastedBits})\n`;
|
||||
|
||||
if (!passed) {
|
||||
failureCount++;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
outputLog += `[${file}]: Error - ${errorMessage}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (checkedCount === 0) {
|
||||
const response: CheckUpconvResponse = {
|
||||
success: true,
|
||||
output: "No 24-bit files found to check.",
|
||||
wastedBits: "N/A"
|
||||
};
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
const response: CheckUpconvResponse = {
|
||||
success: true,
|
||||
output: outputLog,
|
||||
wastedBits: failureCount > 0 ? "Issues Found" : "Clean"
|
||||
};
|
||||
return NextResponse.json(response);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking upconv:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { getOutputDir } from '@/lib/utils/paths';
|
||||
import { copyDirRecursive } from '@/lib/utils/file-ops';
|
||||
import { CopyResponse } from '@/types';
|
||||
|
||||
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 outputDir = getOutputDir();
|
||||
if (!outputDir) {
|
||||
return NextResponse.json({ error: 'OUTPUT_DIR not configured in .env.local' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
await fs.ensureDir(outputDir);
|
||||
|
||||
const albumName = path.basename(albumPath);
|
||||
const destPath = path.join(outputDir, albumName);
|
||||
|
||||
// Check if destination already exists
|
||||
if (await fs.pathExists(destPath)) {
|
||||
return NextResponse.json({ error: 'Album already exists in output directory' }, { status: 409 });
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
await fs.copy(albumPath, destPath);
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === 'EINVAL') {
|
||||
console.warn('fs-extra copy failed with EINVAL, attempting fallback to stream-based copy', error);
|
||||
// Fallback to custom stream-based copy to avoid copyfile syscall
|
||||
await copyDirRecursive(albumPath, destPath);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const response: CopyResponse = { success: true, destPath };
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('Error copying album:', error);
|
||||
return NextResponse.json({ error: 'Failed to copy album' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import mime from 'mime';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const filePath = searchParams.get('path');
|
||||
|
||||
if (!filePath) {
|
||||
return NextResponse.json({ error: 'Path is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Security check: Ensure we are only serving files from allowed locations?
|
||||
// For this local tool, we might be lenient, but ideally we should check if it's in the watch folder.
|
||||
// However, the watch folder location is dynamic.
|
||||
// Let's at least check if it exists.
|
||||
|
||||
try {
|
||||
if (!await fs.pathExists(filePath)) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const stat = await fs.stat(filePath);
|
||||
if (!stat.isFile()) {
|
||||
return NextResponse.json({ error: 'Not a file' }, { status: 400 });
|
||||
}
|
||||
|
||||
const fileBuffer = await fs.readFile(filePath);
|
||||
const contentType = mime.getType(filePath) || 'application/octet-stream';
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': stat.size.toString(),
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error serving file:', error);
|
||||
return NextResponse.json({ error: 'Failed to serve file' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { getSpectrogramsPath } from '@/lib/utils/paths';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { albumPath, upc } = await request.json();
|
||||
|
||||
if (!albumPath || !(await fs.pathExists(albumPath))) {
|
||||
return NextResponse.json({ error: 'Invalid album path' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Delete the album directory
|
||||
await fs.remove(albumPath);
|
||||
|
||||
// 2. Delete the spectrograms directory for this album
|
||||
const specRoot = getSpectrogramsPath();
|
||||
const albumName = path.basename(albumPath);
|
||||
|
||||
// Try UPC first, then album name
|
||||
let specDir = path.join(specRoot, upc || albumName);
|
||||
if (await fs.pathExists(specDir)) {
|
||||
await fs.remove(specDir);
|
||||
} else if (upc && await fs.pathExists(path.join(specRoot, albumName))) {
|
||||
// Fallback: check album name if UPC dir didn't exist but UPC was provided
|
||||
await fs.remove(path.join(specRoot, albumName));
|
||||
}
|
||||
|
||||
// 3. Conditionally delete the generated torrent file
|
||||
if (process.env.DELETE_GENERATED_TORRENTS === 'true') {
|
||||
const parentDir = path.dirname(albumPath);
|
||||
const torrentPath = path.join(parentDir, `${albumName}.torrent`);
|
||||
if (await fs.pathExists(torrentPath)) {
|
||||
await fs.remove(torrentPath);
|
||||
console.log(`Deleted generated torrent: ${torrentPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting album:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete album' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { ProposeRenameResponse, RenameProposal } from '@/types';
|
||||
|
||||
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 folderName = path.basename(albumPath);
|
||||
// Max length for file + folder + separator is 179 (to match frontend check > 179)
|
||||
// So max file length = 179 - folderName.length - 1 (separator)
|
||||
const maxFileLength = 179 - folderName.length - 1;
|
||||
|
||||
const problematicFiles: RenameProposal[] = [];
|
||||
const allFiles: RenameProposal[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(albumPath, file);
|
||||
const stat = await fs.stat(filePath);
|
||||
|
||||
if (stat.isFile()) {
|
||||
const ext = path.extname(file);
|
||||
const name = path.basename(file, ext);
|
||||
|
||||
// Strategy 1: Remove Brackets []
|
||||
const nameNoBrackets = name.replace(/\[.*?\]/g, '').replace(/\s+/g, ' ').trim();
|
||||
const fileNoBrackets = nameNoBrackets + ext;
|
||||
|
||||
// Strategy 2: Remove Parentheses ()
|
||||
const nameNoParens = name.replace(/\(.*?\)/g, '').replace(/\s+/g, ' ').trim();
|
||||
const fileNoParens = nameNoParens + ext;
|
||||
|
||||
// Strategy 3: Remove Both
|
||||
const nameClean = name.replace(/\[.*?\]/g, '').replace(/\(.*?\)/g, '').replace(/\s+/g, ' ').trim();
|
||||
const fileClean = nameClean + ext;
|
||||
|
||||
// Strategy 4: Truncate (fallback if others fail or just as an option)
|
||||
// We need to truncate 'name' so that name + ext <= maxFileLength
|
||||
const maxNameLength = maxFileLength - ext.length;
|
||||
let nameTruncated = name;
|
||||
if (name.length > maxNameLength) {
|
||||
nameTruncated = name.substring(0, maxNameLength).trim();
|
||||
}
|
||||
const fileTruncated = nameTruncated + ext;
|
||||
|
||||
const proposal: RenameProposal = {
|
||||
original: file,
|
||||
strategies: {
|
||||
remove_brackets: fileNoBrackets.length <= maxFileLength ? fileNoBrackets : null,
|
||||
remove_parens: fileNoParens.length <= maxFileLength ? fileNoParens : null,
|
||||
remove_both: fileClean.length <= maxFileLength ? fileClean : null,
|
||||
truncate: fileTruncated
|
||||
}
|
||||
};
|
||||
|
||||
allFiles.push(proposal);
|
||||
|
||||
// Check if file exceeds limit
|
||||
if (file.length > maxFileLength) {
|
||||
problematicFiles.push(proposal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response: ProposeRenameResponse = {
|
||||
success: true,
|
||||
problematicFiles,
|
||||
allFiles,
|
||||
maxFileLength
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error proposing renames:', error);
|
||||
return NextResponse.json({ error: 'Failed to propose renames' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { addTorrent, recheckTorrent } from '@/lib/qbittorrent';
|
||||
import { QbittorrentInjectResponse } from '@/types';
|
||||
|
||||
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 parentDir = path.dirname(albumPath);
|
||||
const albumDirName = path.basename(albumPath);
|
||||
|
||||
// Logic to find the torrent file (similar to torrent creation route)
|
||||
let torrentPath = path.join(parentDir, `${albumDirName}.torrent`);
|
||||
|
||||
if (!await fs.pathExists(torrentPath)) {
|
||||
// Try to find a recent torrent file if exact match fails
|
||||
const files = await fs.readdir(parentDir);
|
||||
const recentTorrent = (await Promise.all(files
|
||||
.filter(f => f.endsWith('.torrent'))
|
||||
.map(async f => {
|
||||
const stat = await fs.stat(path.join(parentDir, f));
|
||||
return { name: f, mtime: stat.mtimeMs };
|
||||
})
|
||||
))
|
||||
.sort((a, b) => b.mtime - a.mtime)[0];
|
||||
|
||||
if (recentTorrent) {
|
||||
torrentPath = path.join(parentDir, recentTorrent.name);
|
||||
} else {
|
||||
return NextResponse.json({ error: 'Torrent file not found. Please create it first.' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
const torrentData = await fs.readFile(torrentPath);
|
||||
const filename = path.basename(torrentPath);
|
||||
|
||||
// 1. Add torrent (paused)
|
||||
const hash = await addTorrent(torrentData, filename, 'RED');
|
||||
|
||||
// 2. Trigger recheck
|
||||
await recheckTorrent(hash);
|
||||
|
||||
const response: QbittorrentInjectResponse = { success: true, hash };
|
||||
return NextResponse.json(response);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error injecting torrent:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return NextResponse.json({
|
||||
error: 'Failed to inject torrent',
|
||||
details: errorMessage
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { resumeTorrent } from '@/lib/qbittorrent';
|
||||
import { QbittorrentResumeResponse } from '@/types';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { hash } = await request.json();
|
||||
|
||||
if (!hash) {
|
||||
return NextResponse.json({ error: 'Hash is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await resumeTorrent(hash);
|
||||
|
||||
const response: QbittorrentResumeResponse = { success: true };
|
||||
return NextResponse.json(response);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error resuming torrent:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return NextResponse.json({
|
||||
error: 'Failed to resume torrent',
|
||||
details: errorMessage
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getTorrentInfo } from '@/lib/qbittorrent';
|
||||
import { QbittorrentStatusResponse } from '@/types';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const hash = searchParams.get('hash');
|
||||
|
||||
if (!hash) {
|
||||
return NextResponse.json({ error: 'Hash is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await getTorrentInfo(hash);
|
||||
if (!info) {
|
||||
return NextResponse.json({ error: 'Torrent not found' }, { status: 404 });
|
||||
}
|
||||
const response: QbittorrentStatusResponse = { success: true, info };
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
console.error('Error getting torrent status:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return NextResponse.json({
|
||||
error: 'Failed to get status',
|
||||
details: errorMessage
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { getFlacMetadata, parseLabel } from '@/lib/utils/audio';
|
||||
import { AnalysisTrack, ApiResponse, AlbumResponse } from '@/types';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { albumPath, releaseType } = 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[] = await Promise.all(
|
||||
flacFiles.map(async (file) => {
|
||||
const filePath = path.join(albumPath, file);
|
||||
const metadata = await getFlacMetadata(filePath);
|
||||
return {
|
||||
filename: file,
|
||||
path: filePath,
|
||||
...metadata,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Sort tracks
|
||||
tracks.sort((a, b) => {
|
||||
if ('error' in a || 'error' in b) return 0;
|
||||
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 for description
|
||||
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;
|
||||
if (!sr || sr < 48000 || !bd || bd < 24) isHiRes = false;
|
||||
});
|
||||
|
||||
if (sampleRates.size > 1 || bitDepths.size > 1) {
|
||||
mixed = true;
|
||||
qualityDetails = `\n\nNOTE: Mixed formats. Sample Rates: ${Array.from(sampleRates).join(', ')} Hz. Bit Depths: ${Array.from(bitDepths).join(', ')} bit.`;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const firstTrack = tracks.find((t): t is Extract<AnalysisTrack, { error?: never }> => !('error' in t));
|
||||
if (!firstTrack) {
|
||||
return NextResponse.json({ error: 'No valid tracks found' }, { status: 400 });
|
||||
}
|
||||
|
||||
const albumName = firstTrack.common?.album || 'Unknown Album';
|
||||
const year = firstTrack.common?.year || (firstTrack.common?.date ? parseInt(firstTrack.common.date.split('-')[0]) : 2025);
|
||||
|
||||
let label = parseLabel(firstTrack.common?.copyright || '');
|
||||
|
||||
const upc = firstTrack.native?.vorbis?.find(tag => tag.id === 'UPC')?.value || firstTrack.common?.barcode || '';
|
||||
|
||||
// Artists
|
||||
// Artists Logic
|
||||
|
||||
// Helper to split artists
|
||||
const splitArtists = (artistString: string) => {
|
||||
return artistString.split(/,\s+|\s+\/\s+|;\s+/).map((a: string) => a.trim()).filter((a: string) => a.length > 0);
|
||||
};
|
||||
|
||||
// Collect all artists and their frequencies
|
||||
const artistCounts = new Map<string, number>();
|
||||
const allUniqueArtists = new Set<string>();
|
||||
|
||||
tracks.forEach(t => {
|
||||
if ('error' in t) return;
|
||||
let trackArtists: string[] = [];
|
||||
// music-metadata might return artists as an array, but sometimes elements are comma-separated strings
|
||||
if (t.common?.artists) {
|
||||
t.common.artists.forEach((a: string) => trackArtists.push(...splitArtists(a)));
|
||||
} else if (t.common?.artist) {
|
||||
trackArtists = splitArtists(t.common.artist);
|
||||
}
|
||||
|
||||
trackArtists.forEach(a => {
|
||||
allUniqueArtists.add(a);
|
||||
artistCounts.set(a, (artistCounts.get(a) || 0) + 1);
|
||||
});
|
||||
});
|
||||
|
||||
let mainArtists: { name: string }[] = [];
|
||||
const withArtists: { name: string }[] = [];
|
||||
|
||||
if (releaseType === 7) {
|
||||
// Compilation: All artists in mainArtists, no withArtists
|
||||
allUniqueArtists.forEach(a => {
|
||||
if (a !== 'Various Artists') {
|
||||
mainArtists.push({ name: a });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Standard Logic
|
||||
// Determine Main Artist String
|
||||
let mainArtistName = firstTrack.common?.albumartist || 'Various Artists';
|
||||
|
||||
// If album artist is Various Artists (or missing), find the most common artist
|
||||
if (mainArtistName === 'Various Artists' || !mainArtistName) {
|
||||
let maxCount = 0;
|
||||
let mostFrequent = '';
|
||||
artistCounts.forEach((count, artist) => {
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
mostFrequent = artist;
|
||||
}
|
||||
});
|
||||
if (mostFrequent) {
|
||||
mainArtistName = mostFrequent;
|
||||
}
|
||||
}
|
||||
|
||||
// Split main artist into components for the array
|
||||
// Use the same split logic as above
|
||||
const mainArtistComponents = splitArtists(mainArtistName);
|
||||
mainArtists = mainArtistComponents.map(name => ({ name }));
|
||||
|
||||
// Determine With Artists
|
||||
allUniqueArtists.forEach(a => {
|
||||
// Exclude all main artist components and "Various Artists" from "with" list
|
||||
if (!mainArtistComponents.includes(a) && a !== 'Various Artists') {
|
||||
withArtists.push({ name: a });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Tracklist
|
||||
const tracklist = tracks.map(t => {
|
||||
if ('error' in t) return `${t.filename} (Error)`;
|
||||
return `${t.common?.track?.no || '00'}. ${t.common?.title || t.filename}`;
|
||||
}).join('\n');
|
||||
|
||||
// Description
|
||||
let description = "Sourced from TIDAL";
|
||||
if (mixed) {
|
||||
description += qualityDetails;
|
||||
}
|
||||
|
||||
// Encoding
|
||||
let encoding = "Lossless";
|
||||
const maxBitDepth = Math.max(...Array.from(bitDepths).map(Number));
|
||||
if (maxBitDepth >= 24) {
|
||||
encoding = "24bit Lossless";
|
||||
}
|
||||
|
||||
const response = {
|
||||
status: "success",
|
||||
response: {
|
||||
source: "Crossfade",
|
||||
group: {
|
||||
wikiBody: tracklist,
|
||||
bbBody: tracklist,
|
||||
name: albumName,
|
||||
year: year,
|
||||
categoryId: 1,
|
||||
releaseType: releaseType || 1, // Default to Album (1)
|
||||
categoryName: "Music",
|
||||
musicInfo: {
|
||||
composers: [],
|
||||
dj: [],
|
||||
artists: mainArtists,
|
||||
with: withArtists,
|
||||
conductor: [],
|
||||
remixedBy: [],
|
||||
producer: []
|
||||
},
|
||||
recordLabel: label,
|
||||
catalogueNumber: upc,
|
||||
tags: [] // Empty for now, hard to guess
|
||||
},
|
||||
torrent: {
|
||||
media: "WEB",
|
||||
format: "FLAC",
|
||||
encoding: encoding,
|
||||
//remastered: true,
|
||||
remasterYear: year,
|
||||
remasterRecordLabel: label,
|
||||
remasterCatalogueNumber: upc,
|
||||
description: description,
|
||||
//fileCount: tracks.length,
|
||||
//filePath: `${mainArtistName} - ${albumName} (${year}) [FLAC]`
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating RED template:', error);
|
||||
return NextResponse.json({ error: 'Failed to generate template' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { sanitizeName, sanitizeAlbumName } from '@/lib/utils/sanitize';
|
||||
|
||||
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);
|
||||
|
||||
// 1. Rename files
|
||||
for (const file of files) {
|
||||
const oldPath = path.join(albumPath, file);
|
||||
const stat = await fs.stat(oldPath);
|
||||
|
||||
if (stat.isFile()) {
|
||||
const ext = path.extname(file);
|
||||
const name = path.basename(file, ext);
|
||||
const cleanName = sanitizeName(name) + ext;
|
||||
|
||||
if (cleanName !== file) {
|
||||
const newPath = path.join(albumPath, cleanName);
|
||||
await fs.rename(oldPath, newPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Rename album folder
|
||||
const parentDir = path.dirname(albumPath);
|
||||
const oldDirName = path.basename(albumPath);
|
||||
const cleanDirName = sanitizeAlbumName(oldDirName);
|
||||
|
||||
let newAlbumPath = albumPath;
|
||||
|
||||
if (cleanDirName !== oldDirName) {
|
||||
newAlbumPath = path.join(parentDir, cleanDirName);
|
||||
await fs.rename(albumPath, newAlbumPath);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
newAlbumPath,
|
||||
message: 'Sanitization complete'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sanitizing album:', error);
|
||||
return NextResponse.json({ error: 'Failed to sanitize album' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { getWatchPath, ensureWatchDir } from '@/lib/utils/paths';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const watchPath = await ensureWatchDir();
|
||||
const entries = await fs.readdir(watchPath, { withFileTypes: true });
|
||||
|
||||
const albums = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map(async (entry) => {
|
||||
const fullPath = path.join(watchPath, entry.name);
|
||||
|
||||
let coverPath: string | null = null;
|
||||
try {
|
||||
const files = await fs.readdir(fullPath);
|
||||
const imageFiles = files.filter(f => /\.(jpg|jpeg|png)$/i.test(f));
|
||||
|
||||
// Priority 1: cover.jpg/jpeg/png
|
||||
const explicitCover = imageFiles.find(f => /^cover\.(jpg|jpeg|png)$/i.test(f));
|
||||
|
||||
if (explicitCover) {
|
||||
coverPath = path.join(fullPath, explicitCover);
|
||||
} else if (imageFiles.length > 0) {
|
||||
// Priority 2: Random image
|
||||
coverPath = path.join(fullPath, imageFiles[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors reading directory
|
||||
}
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
path: fullPath,
|
||||
hasCover: !!coverPath,
|
||||
coverPath
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({ albums });
|
||||
} catch (error) {
|
||||
console.error('Error scanning watch folder:', error);
|
||||
return NextResponse.json({ error: 'Failed to scan watch folder' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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;
|
||||
if (trackNo === undefined || trackNo === null) return;
|
||||
|
||||
const outputFilename = `${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 = `${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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { checkTools } from '@/lib/tool-check';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
const result = await checkTools();
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
// Global cache for the access token to avoid refetching on every request.
|
||||
// This persists in memory as long as the server (or lambda container) is alive.
|
||||
let cachedToken: string | null = null;
|
||||
let tokenExpiry: number = 0;
|
||||
|
||||
async function getAccessToken(clientId: string, clientSecret: string): Promise<string | null> {
|
||||
// Check if we have a valid cached token (with 5 minute buffer)
|
||||
if (cachedToken && Date.now() < tokenExpiry) {
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
console.log("Refreshing TIDAL access token...");
|
||||
try {
|
||||
const authString = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
|
||||
const res = await fetch("https://auth.tidal.com/v1/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Basic ${authString}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
cache: 'no-store' // Ensure we don't get a stale cached response from Next.js
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("TIDAL Auth Failed:", await res.text());
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
cachedToken = data.access_token;
|
||||
// Set expiry buffer (e.g., expires in 1 hour, we refresh after 55 mins)
|
||||
// expires_in is in seconds
|
||||
const expiresIn = data.expires_in || 3600;
|
||||
tokenExpiry = Date.now() + (expiresIn * 1000) - 300000;
|
||||
|
||||
return cachedToken;
|
||||
} catch (error) {
|
||||
console.error("TIDAL Auth Exception:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapper for TIDAL API requests that handles auth and 401 retries
|
||||
async function fetchTidal(url: string, clientId: string, clientSecret: string) {
|
||||
let token = await getAccessToken(clientId, clientSecret);
|
||||
if (!token) throw new Error("Could not obtain access token");
|
||||
|
||||
let res = await fetch(url, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
},
|
||||
});
|
||||
|
||||
// If unauthorized, invalid token, or expired: clear cache and retry once
|
||||
if (res.status === 401) {
|
||||
console.warn("Got 401 from TIDAL, retrying with new token...");
|
||||
cachedToken = null;
|
||||
tokenExpiry = 0;
|
||||
|
||||
token = await getAccessToken(clientId, clientSecret);
|
||||
if (!token) throw new Error("Could not refresh access token");
|
||||
|
||||
res = await fetch(url, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const query = searchParams.get("query");
|
||||
const countryCode = searchParams.get("countryCode") || "US";
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json({ error: "Query parameter is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const clientId = process.env.TIDAL_CLIENT_ID;
|
||||
const clientSecret = process.env.TIDAL_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
return NextResponse.json({ error: "TIDAL credentials not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Search TIDAL
|
||||
const searchUrl = `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(query)}?countryCode=${countryCode}&explicitFilter=include%2C%20exclude&include=albums,albums.artists,albums.coverArt`;
|
||||
|
||||
const searchRes = await fetchTidal(searchUrl, clientId, clientSecret);
|
||||
|
||||
if (!searchRes.ok) {
|
||||
const errorText = await searchRes.text();
|
||||
console.error("TIDAL Search Error:", errorText);
|
||||
return NextResponse.json({ error: "Failed to search TIDAL", details: errorText }, { status: searchRes.status });
|
||||
}
|
||||
|
||||
const searchData = await searchRes.json();
|
||||
|
||||
//console.log("TIDAL Search Data:", JSON.stringify(searchData, null, 2));
|
||||
|
||||
// Parse JSON:API response
|
||||
const albumsMap = new Map<string, unknown>();
|
||||
const artistIds = new Set<string>();
|
||||
const artworkIds = new Set<string>();
|
||||
|
||||
// Process Albums from 'included' to get IDs
|
||||
if (searchData.included) {
|
||||
searchData.included.forEach((item: Record<string, any>) => {
|
||||
if (item.type === "albums") {
|
||||
const attr = item.attributes;
|
||||
|
||||
// Duration parsing
|
||||
let duration = 0;
|
||||
if (attr.duration) {
|
||||
try {
|
||||
const match = attr.duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
||||
if (match) {
|
||||
duration = parseInt(match[1] || "0") * 3600 + parseInt(match[2] || "0") * 60 + parseInt(match[3] || "0");
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
// Collect Artist IDs
|
||||
if (item.relationships?.artists?.data) {
|
||||
const artistsData = Array.isArray(item.relationships.artists.data)
|
||||
? item.relationships.artists.data
|
||||
: [item.relationships.artists.data];
|
||||
artistsData.forEach((a: { id: string }) => artistIds.add(a.id));
|
||||
}
|
||||
|
||||
// Collect CoverArt IDs
|
||||
let coverArtId = null;
|
||||
if (item.relationships?.coverArt?.data) {
|
||||
const coverData = item.relationships.coverArt.data;
|
||||
const firstCover = Array.isArray(coverData) ? coverData[0] : coverData;
|
||||
if (firstCover && firstCover.id) {
|
||||
artworkIds.add(firstCover.id);
|
||||
coverArtId = firstCover.id;
|
||||
}
|
||||
}
|
||||
|
||||
albumsMap.set(item.id, {
|
||||
id: item.id,
|
||||
title: attr.title,
|
||||
artists: [], // To be filled
|
||||
duration: duration,
|
||||
url: attr.externalLinks?.[0]?.href || `https://tidal.com/browse/album/${item.id}`,
|
||||
releaseDate: attr.releaseDate,
|
||||
mediaTags: attr.mediaTags || [],
|
||||
imageCover: [], // To be filled
|
||||
_artistIds: item.relationships?.artists?.data?.map((a: { id: string }) => a.id) || [],
|
||||
_coverArtId: coverArtId
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to chunk array
|
||||
const chunkArray = (array: string[], size: number) => {
|
||||
const chunks = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunks.push(array.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
|
||||
// 2. Bulk Fetch Artists
|
||||
const artistsMap = new Map<string, { name: string }>();
|
||||
if (artistIds.size > 0) {
|
||||
const allArtistIds = Array.from(artistIds);
|
||||
const chunks = chunkArray(allArtistIds, 20); // API Limit: 20
|
||||
|
||||
await Promise.all(chunks.map(async (chunk) => {
|
||||
const ids = chunk.join(",");
|
||||
const artistsUrl = `https://openapi.tidal.com/v2/artists?countryCode=${countryCode}&filter[id]=${ids}`;
|
||||
try {
|
||||
const res = await fetchTidal(artistsUrl, clientId, clientSecret);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
data.data?.forEach((item: Record<string, any>) => {
|
||||
artistsMap.set(item.id, { name: item.attributes.name });
|
||||
});
|
||||
} else {
|
||||
console.error("Failed to fetch artists batch:", await res.text());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error fetching artists batch", e);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// 3. Bulk Fetch Artworks
|
||||
const artworksMap = new Map<string, { url: string, width: number, height: number }[]>();
|
||||
if (artworkIds.size > 0) {
|
||||
const allArtworkIds = Array.from(artworkIds);
|
||||
const chunks = chunkArray(allArtworkIds, 20); // API Limit: 20 (assumed same as artists)
|
||||
|
||||
await Promise.all(chunks.map(async (chunk) => {
|
||||
const ids = chunk.join(",");
|
||||
const artworksUrl = `https://openapi.tidal.com/v2/artworks?countryCode=${countryCode}&filter[id]=${ids}`;
|
||||
try {
|
||||
const res = await fetchTidal(artworksUrl, clientId, clientSecret);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
data.data?.forEach((item: Record<string, any>) => {
|
||||
const images: { url: string, width: number, height: number }[] = [];
|
||||
|
||||
// Structure seen in logs: item.attributes.files is an array of { href, meta: { width, height } }
|
||||
if (item.attributes.files && Array.isArray(item.attributes.files)) {
|
||||
item.attributes.files.forEach((file: Record<string, any>) => {
|
||||
images.push({
|
||||
url: file.href,
|
||||
width: file.meta?.width || 0,
|
||||
height: file.meta?.height || 0
|
||||
});
|
||||
});
|
||||
}
|
||||
// Fallback structure (imageLinks)
|
||||
else if (item.attributes.imageLinks && Array.isArray(item.attributes.imageLinks)) {
|
||||
item.attributes.imageLinks.forEach((link: Record<string, any>) => {
|
||||
images.push({ url: link.href, width: link.width || 0, height: link.height || 0 });
|
||||
});
|
||||
}
|
||||
|
||||
// Priority 3: Fallback - Construct URLs manually if we have an ID and parsing failed
|
||||
if (images.length === 0) {
|
||||
const availableSizes = [80, 160, 320, 640, 1280];
|
||||
availableSizes.forEach(size => {
|
||||
images.push({
|
||||
url: `https://resources.tidal.com/images/${item.id.replace(/-/g, '/')}/${size}x${size}.jpg`,
|
||||
width: size,
|
||||
height: size
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
artworksMap.set(item.id, images);
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error("Error fetching artworks batch", e); }
|
||||
}));
|
||||
}
|
||||
|
||||
// 4. Merge Data
|
||||
const albums = Array.from(albumsMap.values() as IterableIterator<Record<string, any>>).map(album => {
|
||||
// Fill Artists
|
||||
if (album._artistIds) {
|
||||
album.artists = album._artistIds.map((id: string) => artistsMap.get(id) || { name: "Unknown" });
|
||||
}
|
||||
// Fill Artwork
|
||||
if (album._coverArtId) {
|
||||
const covers = artworksMap.get(album._coverArtId);
|
||||
if (covers) {
|
||||
album.imageCover = covers;
|
||||
} else {
|
||||
// Fallback to direct resource URL if we have the ID but fetch failed
|
||||
album.imageCover = [
|
||||
{ url: `https://resources.tidal.com/images/${album._coverArtId.replace(/-/g, '/')}/640x640.jpg`, width: 640, height: 640 },
|
||||
{ url: `https://resources.tidal.com/images/${album._coverArtId.replace(/-/g, '/')}/320x320.jpg`, width: 320, height: 320 }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup internal keys
|
||||
delete album._artistIds;
|
||||
delete album._coverArtId;
|
||||
|
||||
return album;
|
||||
});
|
||||
|
||||
return NextResponse.json({ albums });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Search API Error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { exec } from 'child_process';
|
||||
import util from 'util';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { TorrentCreationResponse } from '@/types';
|
||||
|
||||
const execPromise = util.promisify(exec);
|
||||
|
||||
interface ExecError extends Error {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
}
|
||||
|
||||
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 parentDir = path.dirname(albumPath);
|
||||
const albumDirName = path.basename(albumPath);
|
||||
|
||||
// Command: mkbrr create <folder> -P red
|
||||
// Run in parent directory so the torrent file is created there
|
||||
const command = `mkbrr create "${albumDirName}" -P red`;
|
||||
|
||||
console.log('Running command:', command, 'in', parentDir);
|
||||
|
||||
const { stdout, stderr } = await execPromise(command, { cwd: parentDir });
|
||||
|
||||
// Expected torrent file path
|
||||
let torrentPath = path.join(parentDir, `${albumDirName}.torrent`);
|
||||
|
||||
if (!await fs.pathExists(torrentPath)) {
|
||||
console.log(`Expected torrent file not found at: ${torrentPath}`);
|
||||
console.log('Listing files in parent directory to find match...');
|
||||
|
||||
const files = await fs.readdir(parentDir);
|
||||
console.log('Files in directory:', files);
|
||||
|
||||
// Try to find a .torrent file that was created very recently (last 10 seconds)
|
||||
// and looks related (contains part of the name or just is a torrent file)
|
||||
const recentTorrent = (await Promise.all(files
|
||||
.filter(f => f.endsWith('.torrent'))
|
||||
.map(async f => {
|
||||
const stat = await fs.stat(path.join(parentDir, f));
|
||||
return { name: f, mtime: stat.mtimeMs };
|
||||
})
|
||||
))
|
||||
.sort((a, b) => b.mtime - a.mtime)[0]; // Sort by newest
|
||||
|
||||
if (recentTorrent) {
|
||||
console.log(`Found recent torrent file: ${recentTorrent.name}`);
|
||||
torrentPath = path.join(parentDir, recentTorrent.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (await fs.pathExists(torrentPath)) {
|
||||
const torrentData = await fs.readFile(torrentPath);
|
||||
const response: TorrentCreationResponse = {
|
||||
success: true,
|
||||
output: stdout,
|
||||
filename: path.basename(torrentPath),
|
||||
data: torrentData.toString('base64')
|
||||
};
|
||||
return NextResponse.json(response);
|
||||
} else {
|
||||
const response: TorrentCreationResponse = {
|
||||
success: false,
|
||||
output: stdout,
|
||||
errorOutput: stderr || 'Torrent file not found after generation. Check server logs for file listing.',
|
||||
error: 'Torrent file not found'
|
||||
};
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating torrent:', error);
|
||||
const execError = error as ExecError;
|
||||
return NextResponse.json({
|
||||
error: 'Failed to create torrent',
|
||||
details: execError.message,
|
||||
output: execError.stdout,
|
||||
errorOutput: execError.stderr
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKey = process.env.RED_IMAGE_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: 'Server misconfiguration: No RED API Key' }, { status: 500 });
|
||||
}
|
||||
|
||||
// RED image host requires 'file' POST argument for local file upload
|
||||
const upstreamFormData = new FormData();
|
||||
upstreamFormData.append('file', file);
|
||||
|
||||
const response = await fetch('https://redacted.sh/ajax.php?action=upload_image', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': apiKey,
|
||||
},
|
||||
body: upstreamFormData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed with status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status !== 'success' || !data.response || !data.response.url) {
|
||||
throw new Error(data.error || data.status || 'Invalid response from RED image host');
|
||||
}
|
||||
|
||||
const imageUrl = data.response.url;
|
||||
|
||||
return NextResponse.json({ url: imageUrl });
|
||||
|
||||
} catch (error) {
|
||||
console.error('RED image host upload error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Internal Server Error';
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user