diff --git a/src/routes/admin/audio/+page.server.js b/src/routes/admin/audio/+page.server.js index 18b00be..ff50bbf 100644 --- a/src/routes/admin/audio/+page.server.js +++ b/src/routes/admin/audio/+page.server.js @@ -3,6 +3,46 @@ import { db } from '$lib/server/db/index.js'; import { audioFile } from '$lib/server/db/schema.js'; import { eq, isNull } from 'drizzle-orm'; 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() { const audioFiles = await db.select({ @@ -43,16 +83,19 @@ export const actions = { const s3Key = generateAudioS3Key(id, file.name); try { + // Extract duration before uploading + const duration = await getAudioDuration(buffer); + // Upload to S3 first 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({ id, filename: file.name, contentType: file.type, s3Key, - duration: null, // Will be updated by client-side after upload + duration, fileSize: file.size, createdAt: new Date() }); diff --git a/src/routes/admin/audio/+page.svelte b/src/routes/admin/audio/+page.svelte index 5246abc..6f3b772 100644 --- a/src/routes/admin/audio/+page.svelte +++ b/src/routes/admin/audio/+page.svelte @@ -91,9 +91,6 @@ if (response.ok && !responseText.includes('missing') && !responseText.includes('error')) { uploadProgress[index].status = 'completed'; uploadProgress[index].progress = 100; - - // Extract duration from uploaded file - extractAndSaveDuration(file); } else { uploadProgress[index].status = 'error'; uploadProgress[index].error = 'Upload failed'; @@ -140,48 +137,6 @@ 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) { editingAudioId = audioFile.id; @@ -456,7 +411,7 @@ {formatFileSize(audio.fileSize || 0)} - {audio.duration ? formatTime(audio.duration) : 'Loading...'} + {audio.duration ? formatTime(audio.duration) : '--'} {new Date(audio.createdAt).toLocaleDateString()} diff --git a/src/routes/admin/ratings/+page.server.js b/src/routes/admin/ratings/+page.server.js index 5668bda..6c921d8 100644 --- a/src/routes/admin/ratings/+page.server.js +++ b/src/routes/admin/ratings/+page.server.js @@ -1,14 +1,29 @@ import { db } from '$lib/server/db/index.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() { // Only get active (non-deleted) completed ratings with timeseries data const ratings = await db .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, - audioFile, + audioFile: { + id: audioFile.id, + filename: audioFile.filename, + duration: audioFile.duration, + fileSize: audioFile.fileSize, + createdAt: audioFile.createdAt + }, inviteLink }) .from(rating) @@ -30,8 +45,8 @@ export async function load() { )) .orderBy(desc(rating.createdAt)); - const ratingStats = await db - .select() + const ratingCountResult = await db + .select({ count: count() }) .from(rating) .innerJoin(participant, and( eq(rating.participantId, participant.id), @@ -41,13 +56,21 @@ export async function load() { eq(rating.isCompleted, true), isNull(rating.deletedAt) // Only active ratings )); + + const totalRatings = ratingCountResult[0]?.count || 0; // Get overall ratings const overallRatings = await db .select({ overallRating, participant, - audioFile, + audioFile: { + id: audioFile.id, + filename: audioFile.filename, + duration: audioFile.duration, + fileSize: audioFile.fileSize, + createdAt: audioFile.createdAt + }, inviteLink }) .from(overallRating) @@ -168,7 +191,7 @@ export async function load() { return { ratings, - totalRatings: ratingStats.length, + totalRatings, timeseriesData, overallRatingsMap }; diff --git a/src/routes/admin/ratings/+page.svelte b/src/routes/admin/ratings/+page.svelte index 7315da7..cce49db 100644 --- a/src/routes/admin/ratings/+page.svelte +++ b/src/routes/admin/ratings/+page.svelte @@ -110,7 +110,7 @@

Participant Ratings

-

View and analyze participant ratings data with timeseries visualization.

+

View participant ratings data with timeseries visualization.

0 ? new Date(Math.max(...participantData.ratings.map(r => new Date(r.createdAt).getTime()))) : null; - // Format timeseries data as string - const timeseriesString = participantData.ratings - .map(rating => `${rating.timestamp.toFixed(2)}:${rating.value}`) - .join('; '); + // Use the stored timeseries data from the rating record + const timeseriesData = participantData.ratings.find(r => r.isCompleted && r.timeseriesData)?.timeseriesData || + JSON.stringify(participantData.ratings.map(r => ({ timestamp: r.timestamp, value: r.value }))); worksheet.addRow([ participantData.name, @@ -168,7 +169,7 @@ export async function GET() { participantData.ratings.length, durationCovered.toFixed(2), participantData.overallRating !== undefined ? participantData.overallRating : '-', - timeseriesString, + timeseriesData, lastUpdated ? lastUpdated.toLocaleString() : '-' ]); });