From 5185d4dc0b136ecba7559271a743ce5a28f88479 Mon Sep 17 00:00:00 2001 From: anon28410 Date: Sat, 25 Jul 2026 21:12:38 +0100 Subject: [PATCH] improve audio stream teardown and enhance robust directory deletion with retries --- app/api/audio/route.ts | 16 +++++ app/api/delete/route.ts | 138 ++++++++++++++++++++++++++++++++------ components/album-view.tsx | 8 +++ 3 files changed, 140 insertions(+), 22 deletions(-) diff --git a/app/api/audio/route.ts b/app/api/audio/route.ts index 9b3bd6f..e852360 100644 --- a/app/api/audio/route.ts +++ b/app/api/audio/route.ts @@ -34,6 +34,12 @@ export async function GET(request: Request) { // Convert stream to ReadableStream for NextResponse const stream = new ReadableStream({ start(controller) { + if (request.signal.aborted) { + file.destroy(); + return; + } + const onAbort = () => file.destroy(); + request.signal.addEventListener('abort', onAbort); file.on('data', (chunk) => { try { controller.enqueue(chunk); @@ -43,11 +49,13 @@ export async function GET(request: Request) { } }); file.on('end', () => { + request.signal.removeEventListener('abort', onAbort); try { controller.close(); } catch (e) { } }); file.on('error', (err) => { + request.signal.removeEventListener('abort', onAbort); try { controller.error(err); } catch (e) { } @@ -73,6 +81,12 @@ export async function GET(request: Request) { // Convert stream to ReadableStream for NextResponse const stream = new ReadableStream({ start(controller) { + if (request.signal.aborted) { + file.destroy(); + return; + } + const onAbort = () => file.destroy(); + request.signal.addEventListener('abort', onAbort); file.on('data', (chunk) => { try { controller.enqueue(chunk); @@ -81,11 +95,13 @@ export async function GET(request: Request) { } }); file.on('end', () => { + request.signal.removeEventListener('abort', onAbort); try { controller.close(); } catch (e) { } }); file.on('error', (err) => { + request.signal.removeEventListener('abort', onAbort); try { controller.error(err); } catch (e) { } diff --git a/app/api/delete/route.ts b/app/api/delete/route.ts index 4a16eae..adcd715 100644 --- a/app/api/delete/route.ts +++ b/app/api/delete/route.ts @@ -3,41 +3,135 @@ import fs from 'fs-extra'; import path from 'path'; 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 { + 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) { try { const { albumPath, upc } = await request.json(); - if (!albumPath || !(await fs.pathExists(albumPath))) { + if (!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); + const parentDir = path.dirname(albumPath); + const errors: string[] = []; - // 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 + // 1. Delete generated torrent file(s) FIRST (independent of directory removal) 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}`); + try { + const primaryTorrentPath = path.join(parentDir, `${albumName}.torrent`); + if (await fs.pathExists(primaryTorrentPath)) { + await fs.remove(primaryTorrentPath); + 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) { console.error('Error deleting album:', error); return NextResponse.json({ error: 'Failed to delete album' }, { status: 500 }); diff --git a/components/album-view.tsx b/components/album-view.tsx index b095b77..6c9a343 100644 --- a/components/album-view.tsx +++ b/components/album-view.tsx @@ -1953,6 +1953,14 @@ export function AlbumView({ onComplete={async () => { setDeleting(true); 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 { const res = await fetch("/api/delete", { method: "POST",