improve audio stream teardown and enhance robust directory deletion with retries
This commit is contained in:
@@ -34,6 +34,12 @@ export async function GET(request: Request) {
|
|||||||
// Convert stream to ReadableStream for NextResponse
|
// Convert stream to ReadableStream for NextResponse
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
|
if (request.signal.aborted) {
|
||||||
|
file.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onAbort = () => file.destroy();
|
||||||
|
request.signal.addEventListener('abort', onAbort);
|
||||||
file.on('data', (chunk) => {
|
file.on('data', (chunk) => {
|
||||||
try {
|
try {
|
||||||
controller.enqueue(chunk);
|
controller.enqueue(chunk);
|
||||||
@@ -43,11 +49,13 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
file.on('end', () => {
|
file.on('end', () => {
|
||||||
|
request.signal.removeEventListener('abort', onAbort);
|
||||||
try {
|
try {
|
||||||
controller.close();
|
controller.close();
|
||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
});
|
});
|
||||||
file.on('error', (err) => {
|
file.on('error', (err) => {
|
||||||
|
request.signal.removeEventListener('abort', onAbort);
|
||||||
try {
|
try {
|
||||||
controller.error(err);
|
controller.error(err);
|
||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
@@ -73,6 +81,12 @@ export async function GET(request: Request) {
|
|||||||
// Convert stream to ReadableStream for NextResponse
|
// Convert stream to ReadableStream for NextResponse
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
|
if (request.signal.aborted) {
|
||||||
|
file.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onAbort = () => file.destroy();
|
||||||
|
request.signal.addEventListener('abort', onAbort);
|
||||||
file.on('data', (chunk) => {
|
file.on('data', (chunk) => {
|
||||||
try {
|
try {
|
||||||
controller.enqueue(chunk);
|
controller.enqueue(chunk);
|
||||||
@@ -81,11 +95,13 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
file.on('end', () => {
|
file.on('end', () => {
|
||||||
|
request.signal.removeEventListener('abort', onAbort);
|
||||||
try {
|
try {
|
||||||
controller.close();
|
controller.close();
|
||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
});
|
});
|
||||||
file.on('error', (err) => {
|
file.on('error', (err) => {
|
||||||
|
request.signal.removeEventListener('abort', onAbort);
|
||||||
try {
|
try {
|
||||||
controller.error(err);
|
controller.error(err);
|
||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
|
|||||||
+116
-22
@@ -3,41 +3,135 @@ import fs from 'fs-extra';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getSpectrogramsPath } from '@/lib/utils/paths';
|
import { getSpectrogramsPath } from '@/lib/utils/paths';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Robustly removes a directory with retries, exponential backoff, and manual file-by-file cleanup fallback
|
||||||
|
* to handle transient file locks or pending OS handle releases (e.g. ENOTEMPTY / EBUSY).
|
||||||
|
*/
|
||||||
|
async function removeDirectoryWithRetry(dirPath: string, retries = 5, delayMs = 300): Promise<void> {
|
||||||
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
|
try {
|
||||||
|
if (!(await fs.pathExists(dirPath))) return;
|
||||||
|
|
||||||
|
// Standard recursive force removal with internal retries
|
||||||
|
await fs.rm(dirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(dirPath))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Attempt ${attempt}/${retries} to remove directory ${dirPath} failed:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: If directory still exists, manually remove contents individually
|
||||||
|
try {
|
||||||
|
if (await fs.pathExists(dirPath)) {
|
||||||
|
const files = await fs.readdir(dirPath);
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = path.join(dirPath, file);
|
||||||
|
try {
|
||||||
|
await fs.remove(fullPath);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to remove item inside directory ${fullPath}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try rmdir on the directory
|
||||||
|
await fs.rmdir(dirPath);
|
||||||
|
if (!(await fs.pathExists(dirPath))) return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore intermediate loop errors; retry will continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < retries) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final attempt
|
||||||
|
if (await fs.pathExists(dirPath)) {
|
||||||
|
await fs.remove(dirPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const { albumPath, upc } = await request.json();
|
const { albumPath, upc } = await request.json();
|
||||||
|
|
||||||
if (!albumPath || !(await fs.pathExists(albumPath))) {
|
if (!albumPath) {
|
||||||
return NextResponse.json({ error: 'Invalid album path' }, { status: 400 });
|
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);
|
const albumName = path.basename(albumPath);
|
||||||
|
const parentDir = path.dirname(albumPath);
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
// Try UPC first, then album name
|
// 1. Delete generated torrent file(s) FIRST (independent of directory removal)
|
||||||
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') {
|
if (process.env.DELETE_GENERATED_TORRENTS === 'true') {
|
||||||
const parentDir = path.dirname(albumPath);
|
try {
|
||||||
const torrentPath = path.join(parentDir, `${albumName}.torrent`);
|
const primaryTorrentPath = path.join(parentDir, `${albumName}.torrent`);
|
||||||
if (await fs.pathExists(torrentPath)) {
|
if (await fs.pathExists(primaryTorrentPath)) {
|
||||||
await fs.remove(torrentPath);
|
await fs.remove(primaryTorrentPath);
|
||||||
console.log(`Deleted generated torrent: ${torrentPath}`);
|
console.log(`Deleted generated torrent: ${primaryTorrentPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check for matching torrent files in parent directory
|
||||||
|
if (await fs.pathExists(parentDir)) {
|
||||||
|
const files = await fs.readdir(parentDir);
|
||||||
|
const matchingTorrents = files.filter(f =>
|
||||||
|
f.endsWith('.torrent') && f.toLowerCase().includes(albumName.toLowerCase())
|
||||||
|
);
|
||||||
|
for (const torrentFile of matchingTorrents) {
|
||||||
|
const tPath = path.join(parentDir, torrentFile);
|
||||||
|
if (await fs.pathExists(tPath)) {
|
||||||
|
await fs.remove(tPath);
|
||||||
|
console.log(`Deleted matching torrent: ${tPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (tErr) {
|
||||||
|
console.error('Error deleting generated torrent:', tErr);
|
||||||
|
errors.push(`Torrent deletion warning: ${tErr instanceof Error ? tErr.message : String(tErr)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
// 2. Delete spectrograms directory for this album (independent task)
|
||||||
|
try {
|
||||||
|
const specRoot = getSpectrogramsPath();
|
||||||
|
const specDir = path.join(specRoot, upc || albumName);
|
||||||
|
if (await fs.pathExists(specDir)) {
|
||||||
|
await fs.remove(specDir);
|
||||||
|
}
|
||||||
|
if (upc && albumName !== upc) {
|
||||||
|
const altSpecDir = path.join(specRoot, albumName);
|
||||||
|
if (await fs.pathExists(altSpecDir)) {
|
||||||
|
await fs.remove(altSpecDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (sErr) {
|
||||||
|
console.error('Error deleting spectrograms:', sErr);
|
||||||
|
errors.push(`Spectrogram deletion warning: ${sErr instanceof Error ? sErr.message : String(sErr)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Delete the album directory with retries
|
||||||
|
if (await fs.pathExists(albumPath)) {
|
||||||
|
try {
|
||||||
|
await removeDirectoryWithRetry(albumPath, 5, 300);
|
||||||
|
} catch (dirErr) {
|
||||||
|
console.error('Error deleting album directory:', dirErr);
|
||||||
|
errors.push(`Album directory deletion error: ${dirErr instanceof Error ? dirErr.message : String(dirErr)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify result
|
||||||
|
const directoryStillExists = await fs.pathExists(albumPath);
|
||||||
|
if (directoryStillExists) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: `Failed to completely remove directory: ${path.basename(albumPath)}`,
|
||||||
|
details: errors
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, warnings: errors.length > 0 ? errors : undefined });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting album:', error);
|
console.error('Error deleting album:', error);
|
||||||
return NextResponse.json({ error: 'Failed to delete album' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to delete album' }, { status: 500 });
|
||||||
|
|||||||
@@ -1953,6 +1953,14 @@ export function AlbumView({
|
|||||||
onComplete={async () => {
|
onComplete={async () => {
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
addLog("Deleting album...");
|
addLog("Deleting album...");
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.removeAttribute("src");
|
||||||
|
audioRef.current.load();
|
||||||
|
}
|
||||||
|
setPlayingTrack(null);
|
||||||
|
setIsPlaying(false);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/delete", {
|
const res = await fetch("/api/delete", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
Reference in New Issue
Block a user