Restart repository history from target event

This commit is contained in:
2026-07-18 16:54:26 +01:00
commit e08aa0a667
71 changed files with 15963 additions and 0 deletions
+193
View File
@@ -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 });
}
}
+110
View File
@@ -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 });
}
}
+50
View File
@@ -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 });
}
}
+61
View File
@@ -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 });
}
}
+89
View File
@@ -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 });
}
}
+52
View File
@@ -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 });
}
}
+43
View File
@@ -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 });
}
}
+45
View File
@@ -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 });
}
}
+84
View File
@@ -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 });
}
}
+60
View File
@@ -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 });
}
}
+26
View File
@@ -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 });
}
}
+28
View File
@@ -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 });
}
}
+222
View File
@@ -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 });
}
}
+54
View File
@@ -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 });
}
}
+49
View File
@@ -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 });
}
}
+126
View File
@@ -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',
},
});
}
+7
View File
@@ -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);
}
+286
View File
@@ -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 });
}
}
+90
View File
@@ -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 });
}
}
+48
View File
@@ -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 });
}
}