fixed excel stuff
This commit is contained in:
@@ -3,6 +3,46 @@ import { db } from '$lib/server/db/index.js';
|
|||||||
import { audioFile } from '$lib/server/db/schema.js';
|
import { audioFile } from '$lib/server/db/schema.js';
|
||||||
import { eq, isNull } from 'drizzle-orm';
|
import { eq, isNull } from 'drizzle-orm';
|
||||||
import { uploadToS3, deleteFromS3, generateAudioS3Key } from '$lib/server/s3.js';
|
import { uploadToS3, deleteFromS3, generateAudioS3Key } from '$lib/server/s3.js';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
async function getAudioDuration(buffer) {
|
||||||
|
try {
|
||||||
|
// Create temporary file
|
||||||
|
const tempDir = os.tmpdir();
|
||||||
|
const tempFilePath = path.join(tempDir, `audio_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
||||||
|
|
||||||
|
// Write buffer to temp file
|
||||||
|
await fs.writeFile(tempFilePath, buffer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use ffprobe to get duration
|
||||||
|
const { stdout } = await execAsync(`ffprobe -v quiet -show_entries format=duration -of csv=p=0 "${tempFilePath}"`);
|
||||||
|
const duration = parseFloat(stdout.trim());
|
||||||
|
|
||||||
|
// Clean up temp file
|
||||||
|
await fs.unlink(tempFilePath);
|
||||||
|
|
||||||
|
return isNaN(duration) ? null : duration;
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up temp file even if ffprobe fails
|
||||||
|
try {
|
||||||
|
await fs.unlink(tempFilePath);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
console.error('Error extracting audio duration:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating temp file for duration extraction:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function load() {
|
export async function load() {
|
||||||
const audioFiles = await db.select({
|
const audioFiles = await db.select({
|
||||||
@@ -43,16 +83,19 @@ export const actions = {
|
|||||||
const s3Key = generateAudioS3Key(id, file.name);
|
const s3Key = generateAudioS3Key(id, file.name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Extract duration before uploading
|
||||||
|
const duration = await getAudioDuration(buffer);
|
||||||
|
|
||||||
// Upload to S3 first
|
// Upload to S3 first
|
||||||
await uploadToS3(s3Key, buffer, file.type);
|
await uploadToS3(s3Key, buffer, file.type);
|
||||||
|
|
||||||
// Then save metadata to database (without blob data)
|
// Then save metadata to database with calculated duration
|
||||||
await db.insert(audioFile).values({
|
await db.insert(audioFile).values({
|
||||||
id,
|
id,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
contentType: file.type,
|
contentType: file.type,
|
||||||
s3Key,
|
s3Key,
|
||||||
duration: null, // Will be updated by client-side after upload
|
duration,
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
createdAt: new Date()
|
createdAt: new Date()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,9 +91,6 @@
|
|||||||
if (response.ok && !responseText.includes('missing') && !responseText.includes('error')) {
|
if (response.ok && !responseText.includes('missing') && !responseText.includes('error')) {
|
||||||
uploadProgress[index].status = 'completed';
|
uploadProgress[index].status = 'completed';
|
||||||
uploadProgress[index].progress = 100;
|
uploadProgress[index].progress = 100;
|
||||||
|
|
||||||
// Extract duration from uploaded file
|
|
||||||
extractAndSaveDuration(file);
|
|
||||||
} else {
|
} else {
|
||||||
uploadProgress[index].status = 'error';
|
uploadProgress[index].status = 'error';
|
||||||
uploadProgress[index].error = 'Upload failed';
|
uploadProgress[index].error = 'Upload failed';
|
||||||
@@ -140,48 +137,6 @@
|
|||||||
uploadProgress = newProgress;
|
uploadProgress = newProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractAndSaveDuration(file) {
|
|
||||||
try {
|
|
||||||
const audio = new Audio();
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
|
|
||||||
audio.src = url;
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
audio.addEventListener('loadedmetadata', () => {
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
audio.addEventListener('error', reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (audio.duration && !isNaN(audio.duration)) {
|
|
||||||
// Find the file ID from the most recent upload response
|
|
||||||
// For now, we'll need to match by filename - this is a limitation
|
|
||||||
const matchingFile = data.audioFiles.find(af => af.filename === file.name);
|
|
||||||
if (matchingFile) {
|
|
||||||
await updateFileDuration(matchingFile.id, audio.duration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error extracting duration:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateFileDuration(fileId, duration) {
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('fileId', fileId);
|
|
||||||
formData.append('duration', duration.toString());
|
|
||||||
|
|
||||||
await fetch('?/updateDuration', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating duration:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEdit(audioFile) {
|
function startEdit(audioFile) {
|
||||||
editingAudioId = audioFile.id;
|
editingAudioId = audioFile.id;
|
||||||
@@ -456,7 +411,7 @@
|
|||||||
{formatFileSize(audio.fileSize || 0)}
|
{formatFileSize(audio.fileSize || 0)}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||||
{audio.duration ? formatTime(audio.duration) : 'Loading...'}
|
{audio.duration ? formatTime(audio.duration) : '--'}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
|
||||||
{new Date(audio.createdAt).toLocaleDateString()}
|
{new Date(audio.createdAt).toLocaleDateString()}
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
import { db } from '$lib/server/db/index.js';
|
import { db } from '$lib/server/db/index.js';
|
||||||
import { rating, participant, audioFile, inviteLink, overallRating } from '$lib/server/db/schema.js';
|
import { rating, participant, audioFile, inviteLink, overallRating } from '$lib/server/db/schema.js';
|
||||||
import { eq, isNull, and, desc } from 'drizzle-orm';
|
import { eq, isNull, and, desc, count } from 'drizzle-orm';
|
||||||
|
|
||||||
export async function load() {
|
export async function load() {
|
||||||
// Only get active (non-deleted) completed ratings with timeseries data
|
// Only get active (non-deleted) completed ratings with timeseries data
|
||||||
const ratings = await db
|
const ratings = await db
|
||||||
.select({
|
.select({
|
||||||
rating,
|
rating: {
|
||||||
|
id: rating.id,
|
||||||
|
participantId: rating.participantId,
|
||||||
|
audioFileId: rating.audioFileId,
|
||||||
|
timestamp: rating.timestamp,
|
||||||
|
value: rating.value,
|
||||||
|
isCompleted: rating.isCompleted,
|
||||||
|
timeseriesData: rating.timeseriesData,
|
||||||
|
createdAt: rating.createdAt
|
||||||
|
},
|
||||||
participant,
|
participant,
|
||||||
audioFile,
|
audioFile: {
|
||||||
|
id: audioFile.id,
|
||||||
|
filename: audioFile.filename,
|
||||||
|
duration: audioFile.duration,
|
||||||
|
fileSize: audioFile.fileSize,
|
||||||
|
createdAt: audioFile.createdAt
|
||||||
|
},
|
||||||
inviteLink
|
inviteLink
|
||||||
})
|
})
|
||||||
.from(rating)
|
.from(rating)
|
||||||
@@ -30,8 +45,8 @@ export async function load() {
|
|||||||
))
|
))
|
||||||
.orderBy(desc(rating.createdAt));
|
.orderBy(desc(rating.createdAt));
|
||||||
|
|
||||||
const ratingStats = await db
|
const ratingCountResult = await db
|
||||||
.select()
|
.select({ count: count() })
|
||||||
.from(rating)
|
.from(rating)
|
||||||
.innerJoin(participant, and(
|
.innerJoin(participant, and(
|
||||||
eq(rating.participantId, participant.id),
|
eq(rating.participantId, participant.id),
|
||||||
@@ -41,13 +56,21 @@ export async function load() {
|
|||||||
eq(rating.isCompleted, true),
|
eq(rating.isCompleted, true),
|
||||||
isNull(rating.deletedAt) // Only active ratings
|
isNull(rating.deletedAt) // Only active ratings
|
||||||
));
|
));
|
||||||
|
|
||||||
|
const totalRatings = ratingCountResult[0]?.count || 0;
|
||||||
|
|
||||||
// Get overall ratings
|
// Get overall ratings
|
||||||
const overallRatings = await db
|
const overallRatings = await db
|
||||||
.select({
|
.select({
|
||||||
overallRating,
|
overallRating,
|
||||||
participant,
|
participant,
|
||||||
audioFile,
|
audioFile: {
|
||||||
|
id: audioFile.id,
|
||||||
|
filename: audioFile.filename,
|
||||||
|
duration: audioFile.duration,
|
||||||
|
fileSize: audioFile.fileSize,
|
||||||
|
createdAt: audioFile.createdAt
|
||||||
|
},
|
||||||
inviteLink
|
inviteLink
|
||||||
})
|
})
|
||||||
.from(overallRating)
|
.from(overallRating)
|
||||||
@@ -168,7 +191,7 @@ export async function load() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ratings,
|
ratings,
|
||||||
totalRatings: ratingStats.length,
|
totalRatings,
|
||||||
timeseriesData,
|
timeseriesData,
|
||||||
overallRatingsMap
|
overallRatingsMap
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
<div class="mb-8 flex items-center justify-between">
|
<div class="mb-8 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="mb-2 text-2xl font-bold text-gray-900">Participant Ratings</h1>
|
<h1 class="mb-2 text-2xl font-bold text-gray-900">Participant Ratings</h1>
|
||||||
<p class="text-gray-600">View and analyze participant ratings data with timeseries visualization.</p>
|
<p class="text-gray-600">View participant ratings data with timeseries visualization.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export async function GET() {
|
|||||||
eq(participant.inviteToken, inviteLink.token),
|
eq(participant.inviteToken, inviteLink.token),
|
||||||
isNull(inviteLink.deletedAt)
|
isNull(inviteLink.deletedAt)
|
||||||
))
|
))
|
||||||
|
.where(isNull(rating.deletedAt))
|
||||||
.orderBy(desc(rating.createdAt));
|
.orderBy(desc(rating.createdAt));
|
||||||
|
|
||||||
// Get overall ratings
|
// Get overall ratings
|
||||||
@@ -71,6 +72,7 @@ export async function GET() {
|
|||||||
timestamp: entry.rating.timestamp,
|
timestamp: entry.rating.timestamp,
|
||||||
value: entry.rating.value,
|
value: entry.rating.value,
|
||||||
isCompleted: entry.rating.isCompleted,
|
isCompleted: entry.rating.isCompleted,
|
||||||
|
timeseriesData: entry.rating.timeseriesData,
|
||||||
createdAt: entry.rating.createdAt
|
createdAt: entry.rating.createdAt
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,10 +158,9 @@ export async function GET() {
|
|||||||
const lastUpdated = participantData.ratings.length > 0 ?
|
const lastUpdated = participantData.ratings.length > 0 ?
|
||||||
new Date(Math.max(...participantData.ratings.map(r => new Date(r.createdAt).getTime()))) : null;
|
new Date(Math.max(...participantData.ratings.map(r => new Date(r.createdAt).getTime()))) : null;
|
||||||
|
|
||||||
// Format timeseries data as string
|
// Use the stored timeseries data from the rating record
|
||||||
const timeseriesString = participantData.ratings
|
const timeseriesData = participantData.ratings.find(r => r.isCompleted && r.timeseriesData)?.timeseriesData ||
|
||||||
.map(rating => `${rating.timestamp.toFixed(2)}:${rating.value}`)
|
JSON.stringify(participantData.ratings.map(r => ({ timestamp: r.timestamp, value: r.value })));
|
||||||
.join('; ');
|
|
||||||
|
|
||||||
worksheet.addRow([
|
worksheet.addRow([
|
||||||
participantData.name,
|
participantData.name,
|
||||||
@@ -168,7 +169,7 @@ export async function GET() {
|
|||||||
participantData.ratings.length,
|
participantData.ratings.length,
|
||||||
durationCovered.toFixed(2),
|
durationCovered.toFixed(2),
|
||||||
participantData.overallRating !== undefined ? participantData.overallRating : '-',
|
participantData.overallRating !== undefined ? participantData.overallRating : '-',
|
||||||
timeseriesString,
|
timeseriesData,
|
||||||
lastUpdated ? lastUpdated.toLocaleString() : '-'
|
lastUpdated ? lastUpdated.toLocaleString() : '-'
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user