reworked upload system to be more robust
This commit is contained in:
@@ -4,7 +4,6 @@ const nextConfig: NextConfig = {
|
|||||||
/* config options here */
|
/* config options here */
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '10000mb'
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
|||||||
@@ -4,62 +4,71 @@ import db from '@/lib/db';
|
|||||||
import { dataset_entry } from '@/lib/model/dataset_entry';
|
import { dataset_entry } from '@/lib/model/dataset_entry';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { writeFile, mkdir, readFile, rm, readdir } from 'fs/promises';
|
import { writeFile, mkdir, readFile, rm } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import extract from 'extract-zip';
|
|
||||||
import { line } from 'drizzle-orm/pg-core';
|
|
||||||
import { requireAdmin } from '@/lib/auth';
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
interface DatasetEntryInput {
|
/**
|
||||||
id: string;
|
* CSV parser that handles quoted fields
|
||||||
audio_file: string;
|
*/
|
||||||
duration_ms: string;
|
function parseCSVLine(line: string): string[] {
|
||||||
speaker: string;
|
const values: string[] = [];
|
||||||
model: string;
|
let current = '';
|
||||||
dialect: string;
|
let inQuotes = false;
|
||||||
iteration: string;
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
const nextChar = line[i + 1];
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && nextChar === '"') {
|
||||||
|
// Escaped quote
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
// Toggle quote state
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
// Field separator
|
||||||
|
values.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the last field
|
||||||
|
values.push(current.trim());
|
||||||
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadDatasetEntries(
|
/**
|
||||||
|
* Step 2: Process the extracted ZIP data and insert into database
|
||||||
|
* Copies audio files and creates database entries
|
||||||
|
*/
|
||||||
|
export async function processDatasetEntries(
|
||||||
datasetId: number,
|
datasetId: number,
|
||||||
formData: FormData
|
tempDir: string
|
||||||
) {
|
) {
|
||||||
const result = await requireAdmin();
|
const result = await requireAdmin();
|
||||||
if (!result.authenticated || !result.admin) {
|
if (!result.authenticated || !result.admin) {
|
||||||
throw new Error('Unauthorized');
|
throw new Error('Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = formData.get('file') as File;
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
throw new Error('No file provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!file.name.endsWith('.zip')) {
|
|
||||||
throw new Error('File must be a ZIP archive');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempDir = join(process.cwd(), 'tmp', `upload-${Date.now()}`);
|
|
||||||
const datasetDir = join(process.cwd(), 'public', 'datasets', datasetId.toString());
|
const datasetDir = join(process.cwd(), 'public', 'datasets', datasetId.toString());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create temp directory
|
// Validate temp directory exists
|
||||||
await mkdir(tempDir, { recursive: true });
|
if (!existsSync(tempDir)) {
|
||||||
|
throw new Error('Temporary extraction directory not found');
|
||||||
|
}
|
||||||
|
|
||||||
// Save uploaded file to temp location
|
// Read metadata.csv again
|
||||||
const tempZipPath = join(tempDir, file.name);
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
|
||||||
await writeFile(tempZipPath, buffer);
|
|
||||||
|
|
||||||
// Extract ZIP file
|
|
||||||
await extract( tempZipPath, { dir: tempDir } );
|
|
||||||
|
|
||||||
// Read metadata.csv
|
|
||||||
const metadataPath = join(tempDir, 'metadata.csv');
|
const metadataPath = join(tempDir, 'metadata.csv');
|
||||||
if (!existsSync(metadataPath)) {
|
if (!existsSync(metadataPath)) {
|
||||||
throw new Error('metadata.csv not found in ZIP');
|
throw new Error('metadata.csv not found in extracted files');
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadataContent = await readFile(metadataPath, 'utf-8');
|
const metadataContent = await readFile(metadataPath, 'utf-8');
|
||||||
@@ -69,48 +78,14 @@ export async function uploadDatasetEntries(
|
|||||||
throw new Error('metadata.csv is empty or invalid');
|
throw new Error('metadata.csv is empty or invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse CSV header
|
// Parse CSV
|
||||||
const headers = lines[0].split(',').map(h => h.trim());
|
const headers = lines[0].split(',').map(h => h.trim());
|
||||||
|
const entries: typeof dataset_entry.$inferInsert[] = [];
|
||||||
|
|
||||||
// Create dataset directory
|
// Create dataset directory
|
||||||
await mkdir(datasetDir, { recursive: true });
|
await mkdir(datasetDir, { recursive: true });
|
||||||
|
|
||||||
// Parse and insert entries
|
// Process each row
|
||||||
const entries: typeof dataset_entry.$inferInsert[] = [];
|
|
||||||
|
|
||||||
// CSV parser that handles quoted fields
|
|
||||||
const parseCSVLine = (line: string): string[] => {
|
|
||||||
const values: string[] = [];
|
|
||||||
let current = '';
|
|
||||||
let inQuotes = false;
|
|
||||||
|
|
||||||
for (let i = 0; i < line.length; i++) {
|
|
||||||
const char = line[i];
|
|
||||||
const nextChar = line[i + 1];
|
|
||||||
|
|
||||||
if (char === '"') {
|
|
||||||
if (inQuotes && nextChar === '"') {
|
|
||||||
// Escaped quote
|
|
||||||
current += '"';
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
// Toggle quote state
|
|
||||||
inQuotes = !inQuotes;
|
|
||||||
}
|
|
||||||
} else if (char === ',' && !inQuotes) {
|
|
||||||
// Field separator
|
|
||||||
values.push(current.trim());
|
|
||||||
current = '';
|
|
||||||
} else {
|
|
||||||
current += char;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the last field
|
|
||||||
values.push(current.trim());
|
|
||||||
return values;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
for (let i = 1; i < lines.length; i++) {
|
||||||
const values = parseCSVLine(lines[i]);
|
const values = parseCSVLine(lines[i]);
|
||||||
|
|
||||||
@@ -121,11 +96,10 @@ export async function uploadDatasetEntries(
|
|||||||
const row: Record<string, string> = {};
|
const row: Record<string, string> = {};
|
||||||
headers.forEach((header, index) => {
|
headers.forEach((header, index) => {
|
||||||
row[header] = values[index];
|
row[header] = values[index];
|
||||||
})
|
});
|
||||||
|
|
||||||
const audioFile = row.audio_file as string;
|
const audioFile = row.audio_file as string;
|
||||||
// Try different path separators and remove /output prefix
|
const relativePath = audioFile.replace(/\\/g, '/');
|
||||||
const relativePath = audioFile.replace(/\\/g, '/')
|
|
||||||
const audioPath = join(tempDir, relativePath);
|
const audioPath = join(tempDir, relativePath);
|
||||||
|
|
||||||
if (existsSync(audioPath)) {
|
if (existsSync(audioPath)) {
|
||||||
@@ -149,7 +123,7 @@ export async function uploadDatasetEntries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
throw new Error('No valid entries found in metadata.csv');
|
throw new Error('No valid audio files found for entries');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for existing entries to avoid duplicates
|
// Check for existing entries to avoid duplicates
|
||||||
@@ -174,12 +148,13 @@ export async function uploadDatasetEntries(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
entriesCreated: entries.length,
|
entriesCreated: newEntries.length,
|
||||||
|
message: `Successfully processed and inserted ${newEntries.length} entries.`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading dataset entries:', error);
|
console.error('Error processing dataset entries:', error);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
error instanceof Error ? error.message : 'Failed to upload dataset entries'
|
error instanceof Error ? error.message : 'Failed to process dataset entries'
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up temp directory
|
// Clean up temp directory
|
||||||
|
|||||||
135
src/app/api/upload/route.ts
Normal file
135
src/app/api/upload/route.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { writeFile, mkdir, readFile, rm } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import extract from 'extract-zip';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
function parseCSVLine(line: string): string[] {
|
||||||
|
const values: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
const nextChar = line[i + 1];
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && nextChar === '"') {
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
values.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(current.trim());
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdmin();
|
||||||
|
if (!result.authenticated || !result.admin) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File;
|
||||||
|
const datasetId = formData.get('datasetId') as string;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No file provided' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.name.endsWith('.zip')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'File must be a ZIP archive' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = join(process.cwd(), 'tmp', `upload-${Date.now()}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create temp directory
|
||||||
|
await mkdir(tempDir, { recursive: true });
|
||||||
|
|
||||||
|
// Save uploaded file to temp location
|
||||||
|
const tempZipPath = join(tempDir, file.name);
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
await writeFile(tempZipPath, buffer);
|
||||||
|
|
||||||
|
// Extract ZIP file
|
||||||
|
await extract(tempZipPath, { dir: tempDir });
|
||||||
|
|
||||||
|
// Read and validate metadata.csv
|
||||||
|
const metadataPath = join(tempDir, 'metadata.csv');
|
||||||
|
if (!existsSync(metadataPath)) {
|
||||||
|
throw new Error('metadata.csv not found in ZIP');
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataContent = await readFile(metadataPath, 'utf-8');
|
||||||
|
const lines = metadataContent.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
|
if (lines.length < 2) {
|
||||||
|
throw new Error('metadata.csv is empty or invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse CSV header
|
||||||
|
const headers = lines[0].split(',').map(h => h.trim());
|
||||||
|
|
||||||
|
// Parse and count rows
|
||||||
|
let validRowCount = 0;
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const values = parseCSVLine(lines[i]);
|
||||||
|
if (values.length === headers.length) {
|
||||||
|
validRowCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validRowCount === 0) {
|
||||||
|
throw new Error('No valid entries found in metadata.csv');
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
tempDir,
|
||||||
|
metadataCount: validRowCount,
|
||||||
|
message: `Successfully extracted ${validRowCount} entries. Ready for processing.`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up on error
|
||||||
|
try {
|
||||||
|
if (existsSync(tempDir)) {
|
||||||
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.error('Error cleaning up temp directory:', cleanupError);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading dataset ZIP:', error);
|
||||||
|
const errorMsg =
|
||||||
|
error instanceof Error ? error.message : 'Failed to upload dataset ZIP';
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: errorMsg },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { uploadDatasetEntries } from '@/app/actions/upload';
|
import { processDatasetEntries } from '@/app/actions/upload';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
|
|
||||||
interface UploadDatasetEntriesModalProps {
|
interface UploadDatasetEntriesModalProps {
|
||||||
@@ -19,6 +19,7 @@ export default function UploadDatasetEntriesModal({
|
|||||||
const [entriesCreated, setEntriesCreated] = useState(0);
|
const [entriesCreated, setEntriesCreated] = useState(0);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [tempDir, setTempDir] = useState<string | null>(null);
|
||||||
|
|
||||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const selectedFile = e.target.files?.[0];
|
const selectedFile = e.target.files?.[0];
|
||||||
@@ -39,18 +40,42 @@ export default function UploadDatasetEntriesModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setStatus('Preparing upload...');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Step 1: Upload and extract ZIP via API route
|
||||||
|
setStatus('Uploading and extracting ZIP file...');
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
formData.append('datasetId', datasetId.toString());
|
||||||
|
|
||||||
const result = await uploadDatasetEntries(datasetId, formData);
|
const uploadResponse = await fetch('/api/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
const errorData = await uploadResponse.json();
|
||||||
|
throw new Error(errorData.error || 'Failed to upload ZIP file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadResult = await uploadResponse.json();
|
||||||
|
|
||||||
|
if (!uploadResult.success) {
|
||||||
|
throw new Error('Failed to upload ZIP file');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTempDir(uploadResult.tempDir);
|
||||||
|
setStatus(`${uploadResult.message} Now processing...`);
|
||||||
|
|
||||||
|
// Step 2: Process the extracted data (server action)
|
||||||
|
setStatus('Processing entries and copying audio files...');
|
||||||
|
const processResult = await processDatasetEntries(datasetId, uploadResult.tempDir);
|
||||||
|
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
setEntriesCreated(result.entriesCreated);
|
setEntriesCreated(processResult.entriesCreated);
|
||||||
setStatus(`Successfully created ${result.entriesCreated} entries!`);
|
setStatus(processResult.message);
|
||||||
setFile(null);
|
setFile(null);
|
||||||
|
setTempDir(null);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setSuccess(false);
|
setSuccess(false);
|
||||||
@@ -60,7 +85,7 @@ export default function UploadDatasetEntriesModal({
|
|||||||
}, 2500);
|
}, 2500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMsg =
|
const errorMsg =
|
||||||
err instanceof Error ? err.message : 'Failed to upload dataset entries';
|
err instanceof Error ? err.message : 'Failed to upload and process dataset entries';
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
setStatus('');
|
setStatus('');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -75,6 +100,7 @@ export default function UploadDatasetEntriesModal({
|
|||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(false);
|
setSuccess(false);
|
||||||
setStatus('');
|
setStatus('');
|
||||||
|
setTempDir(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +125,7 @@ export default function UploadDatasetEntriesModal({
|
|||||||
disabled: loading,
|
disabled: loading,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: loading ? 'Uploading...' : 'Upload',
|
label: loading ? 'Uploading & Processing...' : 'Upload',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const form = document.getElementById(
|
const form = document.getElementById(
|
||||||
'uploadForm'
|
'uploadForm'
|
||||||
@@ -165,8 +191,8 @@ export default function UploadDatasetEntriesModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{success && (
|
{status && (
|
||||||
<div className="p-3 bg-green-100 border border-green-400 text-green-700 rounded text-sm">
|
<div className={`p-3 rounded text-sm ${success ? 'bg-green-100 border border-green-400 text-green-700' : 'bg-blue-100 border border-blue-400 text-blue-700'}`}>
|
||||||
{status}
|
{status}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
0
src/lib/model/upload_status.ts
Normal file
0
src/lib/model/upload_status.ts
Normal file
Reference in New Issue
Block a user