diff --git a/apps/api/src/routes/stems.ts b/apps/api/src/routes/stems.ts index 10768c9..34a456b 100644 --- a/apps/api/src/routes/stems.ts +++ b/apps/api/src/routes/stems.ts @@ -5,13 +5,12 @@ import { requestStemUploadUrlSchema, createStemSchema } from '@music-hub/shared' import { tracks, stems, projectMembers } from '@music-hub/db'; import { requireAuth } from '../middleware/auth.js'; import { createUploadUrl, getObjectBuffer, deleteObject } from '../storage/s3.js'; -import { zipSync } from 'fflate'; +import { zip } from 'fflate'; import type { AppEnv } from '../types.js'; export const stemRoutes = new Hono() .use('*', requireAuth) - // List stems for a track .get('/track/:trackId', async (c) => { const db = c.get('db'); const userId = c.get('userId'); @@ -36,7 +35,6 @@ export const stemRoutes = new Hono() return c.json({ stems: trackStems }); }) - // Request presigned upload URL .post('/track/:trackId/upload-url', zValidator('json', requestStemUploadUrlSchema), async (c) => { const db = c.get('db'); const userId = c.get('userId'); @@ -60,7 +58,6 @@ export const stemRoutes = new Hono() return c.json({ uploadUrl, fileKey, stemId }); }) - // Register stem after upload .post('/track/:trackId', zValidator('json', createStemSchema), async (c) => { const db = c.get('db'); const userId = c.get('userId'); @@ -93,7 +90,6 @@ export const stemRoutes = new Hono() return c.json({ stem }, 201); }) - // Delete stem .delete('/:id', async (c) => { const db = c.get('db'); const userId = c.get('userId'); @@ -103,10 +99,12 @@ export const stemRoutes = new Hono() if (!stem) return c.json({ error: 'Not found' }, 404); const [track] = await db.select().from(tracks).where(eq(tracks.id, stem.trackId)).limit(1); + if (!track) return c.json({ error: 'Not found' }, 404); + const [membership] = await db .select() .from(projectMembers) - .where(and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId))) + .where(and(eq(projectMembers.projectId, track.projectId), eq(projectMembers.userId, userId))) .limit(1); if (!membership || (membership.role !== 'owner' && stem.createdById !== userId)) { @@ -118,7 +116,6 @@ export const stemRoutes = new Hono() return c.json({ message: 'Stem deleted' }); }) - // Download all stems as ZIP .get('/track/:trackId/download-zip', async (c) => { const db = c.get('db'); const userId = c.get('userId'); @@ -142,23 +139,21 @@ export const stemRoutes = new Hono() if (trackStems.length === 0) return c.json({ error: 'No stems found' }, 404); - // Download all files and build ZIP - const files: Record = {}; - await Promise.all( - trackStems.map(async (stem) => { - const buf = await getObjectBuffer(stem.fileKey); - files[stem.originalFileName] = buf; - }), + const fileEntries = await Promise.all( + trackStems.map(async (stem) => [stem.originalFileName, await getObjectBuffer(stem.fileKey)] as const), ); + const files = Object.fromEntries(fileEntries) as Record; - const zipped = zipSync(files); + const zipped = await new Promise((resolve, reject) => { + zip(files, (err, data) => (err ? reject(err) : resolve(data))); + }); const zipName = `${track.name.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-stems.zip`; + const body = zipped.buffer.slice(zipped.byteOffset, zipped.byteOffset + zipped.byteLength) as ArrayBuffer; - return new Response(zipped, { + return new Response(new Blob([body], { type: 'application/zip' }), { headers: { - 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="${zipName}"`, - 'Content-Length': String(zipped.length), + 'Content-Length': String(zipped.byteLength), }, }); }); diff --git a/apps/web/src/lib/components/audio/StemUploadDropzone.svelte b/apps/web/src/lib/components/audio/StemUploadDropzone.svelte index 5175a86..0553fe5 100644 --- a/apps/web/src/lib/components/audio/StemUploadDropzone.svelte +++ b/apps/web/src/lib/components/audio/StemUploadDropzone.svelte @@ -72,7 +72,7 @@ step = 'S3'; await uploadWithProgress(uploadUrl, file, mimeType, (p) => { - files[idx] = { ...files[idx], progress: p }; + files[idx].progress = p; }); step = 'DB'; diff --git a/apps/web/src/lib/utils/format.ts b/apps/web/src/lib/utils/format.ts index f19e48b..0dd4c39 100644 --- a/apps/web/src/lib/utils/format.ts +++ b/apps/web/src/lib/utils/format.ts @@ -1,3 +1,8 @@ +export function formatFileSize(bytes: number): string { + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + export function formatTime(seconds: number): string { const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); diff --git a/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte index 3873a80..0a2b429 100644 --- a/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte +++ b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/+page.svelte @@ -23,7 +23,7 @@ import VersionGraph from './components/VersionGraph.svelte'; import ShareModal from './components/ShareModal.svelte'; import CommentSection from './components/CommentSection.svelte'; - import StemList from './components/StemList.svelte'; + import StemList, { type Stem } from './components/StemList.svelte'; type Version = { id: string; @@ -86,7 +86,6 @@ let branchFromId = $state(null); let branchLabelInput = $state(''); let shareOpen = $state(false); - type Stem = { id: string; name: string; originalFileName: string; mimeType: string; fileSize: number; createdAt: string; createdById: string }; let stems = $state([]); let panelTab = $state<'versions' | 'comments' | 'stems'>('versions'); let panelOpen = $state(true); diff --git a/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/components/StemList.svelte b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/components/StemList.svelte index f992e8a..c671190 100644 --- a/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/components/StemList.svelte +++ b/apps/web/src/routes/(app)/projects/[projectId]/tracks/[trackId]/components/StemList.svelte @@ -1,11 +1,12 @@