refactor: simplify stem code per review
- async zip() instead of zipSync (non-blocking for large files) - null check for track in DELETE endpoint (was non-null assertion) - formatFileSize extracted to format.ts, imported in StemList - Stem type exported from StemList, removed duplicate in +page.svelte - files[idx].progress = p direct Svelte 5 mutation (no spread) - remove narrative comments from stems.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,13 +5,12 @@ import { requestStemUploadUrlSchema, createStemSchema } from '@music-hub/shared'
|
|||||||
import { tracks, stems, projectMembers } from '@music-hub/db';
|
import { tracks, stems, projectMembers } from '@music-hub/db';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
import { createUploadUrl, getObjectBuffer, deleteObject } from '../storage/s3.js';
|
import { createUploadUrl, getObjectBuffer, deleteObject } from '../storage/s3.js';
|
||||||
import { zipSync } from 'fflate';
|
import { zip } from 'fflate';
|
||||||
import type { AppEnv } from '../types.js';
|
import type { AppEnv } from '../types.js';
|
||||||
|
|
||||||
export const stemRoutes = new Hono<AppEnv>()
|
export const stemRoutes = new Hono<AppEnv>()
|
||||||
.use('*', requireAuth)
|
.use('*', requireAuth)
|
||||||
|
|
||||||
// List stems for a track
|
|
||||||
.get('/track/:trackId', async (c) => {
|
.get('/track/:trackId', async (c) => {
|
||||||
const db = c.get('db');
|
const db = c.get('db');
|
||||||
const userId = c.get('userId');
|
const userId = c.get('userId');
|
||||||
@@ -36,7 +35,6 @@ export const stemRoutes = new Hono<AppEnv>()
|
|||||||
return c.json({ stems: trackStems });
|
return c.json({ stems: trackStems });
|
||||||
})
|
})
|
||||||
|
|
||||||
// Request presigned upload URL
|
|
||||||
.post('/track/:trackId/upload-url', zValidator('json', requestStemUploadUrlSchema), async (c) => {
|
.post('/track/:trackId/upload-url', zValidator('json', requestStemUploadUrlSchema), async (c) => {
|
||||||
const db = c.get('db');
|
const db = c.get('db');
|
||||||
const userId = c.get('userId');
|
const userId = c.get('userId');
|
||||||
@@ -60,7 +58,6 @@ export const stemRoutes = new Hono<AppEnv>()
|
|||||||
return c.json({ uploadUrl, fileKey, stemId });
|
return c.json({ uploadUrl, fileKey, stemId });
|
||||||
})
|
})
|
||||||
|
|
||||||
// Register stem after upload
|
|
||||||
.post('/track/:trackId', zValidator('json', createStemSchema), async (c) => {
|
.post('/track/:trackId', zValidator('json', createStemSchema), async (c) => {
|
||||||
const db = c.get('db');
|
const db = c.get('db');
|
||||||
const userId = c.get('userId');
|
const userId = c.get('userId');
|
||||||
@@ -93,7 +90,6 @@ export const stemRoutes = new Hono<AppEnv>()
|
|||||||
return c.json({ stem }, 201);
|
return c.json({ stem }, 201);
|
||||||
})
|
})
|
||||||
|
|
||||||
// Delete stem
|
|
||||||
.delete('/:id', async (c) => {
|
.delete('/:id', async (c) => {
|
||||||
const db = c.get('db');
|
const db = c.get('db');
|
||||||
const userId = c.get('userId');
|
const userId = c.get('userId');
|
||||||
@@ -103,10 +99,12 @@ export const stemRoutes = new Hono<AppEnv>()
|
|||||||
if (!stem) return c.json({ error: 'Not found' }, 404);
|
if (!stem) return c.json({ error: 'Not found' }, 404);
|
||||||
|
|
||||||
const [track] = await db.select().from(tracks).where(eq(tracks.id, stem.trackId)).limit(1);
|
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
|
const [membership] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(projectMembers)
|
.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);
|
.limit(1);
|
||||||
|
|
||||||
if (!membership || (membership.role !== 'owner' && stem.createdById !== userId)) {
|
if (!membership || (membership.role !== 'owner' && stem.createdById !== userId)) {
|
||||||
@@ -118,7 +116,6 @@ export const stemRoutes = new Hono<AppEnv>()
|
|||||||
return c.json({ message: 'Stem deleted' });
|
return c.json({ message: 'Stem deleted' });
|
||||||
})
|
})
|
||||||
|
|
||||||
// Download all stems as ZIP
|
|
||||||
.get('/track/:trackId/download-zip', async (c) => {
|
.get('/track/:trackId/download-zip', async (c) => {
|
||||||
const db = c.get('db');
|
const db = c.get('db');
|
||||||
const userId = c.get('userId');
|
const userId = c.get('userId');
|
||||||
@@ -142,23 +139,21 @@ export const stemRoutes = new Hono<AppEnv>()
|
|||||||
|
|
||||||
if (trackStems.length === 0) return c.json({ error: 'No stems found' }, 404);
|
if (trackStems.length === 0) return c.json({ error: 'No stems found' }, 404);
|
||||||
|
|
||||||
// Download all files and build ZIP
|
const fileEntries = await Promise.all(
|
||||||
const files: Record<string, Uint8Array> = {};
|
trackStems.map(async (stem) => [stem.originalFileName, await getObjectBuffer(stem.fileKey)] as const),
|
||||||
await Promise.all(
|
|
||||||
trackStems.map(async (stem) => {
|
|
||||||
const buf = await getObjectBuffer(stem.fileKey);
|
|
||||||
files[stem.originalFileName] = buf;
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
const files = Object.fromEntries(fileEntries) as Record<string, Uint8Array>;
|
||||||
|
|
||||||
const zipped = zipSync(files);
|
const zipped = await new Promise<Uint8Array>((resolve, reject) => {
|
||||||
|
zip(files, (err, data) => (err ? reject(err) : resolve(data)));
|
||||||
|
});
|
||||||
const zipName = `${track.name.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-stems.zip`;
|
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: {
|
headers: {
|
||||||
'Content-Type': 'application/zip',
|
|
||||||
'Content-Disposition': `attachment; filename="${zipName}"`,
|
'Content-Disposition': `attachment; filename="${zipName}"`,
|
||||||
'Content-Length': String(zipped.length),
|
'Content-Length': String(zipped.byteLength),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
|
|
||||||
step = 'S3';
|
step = 'S3';
|
||||||
await uploadWithProgress(uploadUrl, file, mimeType, (p) => {
|
await uploadWithProgress(uploadUrl, file, mimeType, (p) => {
|
||||||
files[idx] = { ...files[idx], progress: p };
|
files[idx].progress = p;
|
||||||
});
|
});
|
||||||
|
|
||||||
step = 'DB';
|
step = 'DB';
|
||||||
|
|||||||
@@ -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 {
|
export function formatTime(seconds: number): string {
|
||||||
const m = Math.floor(seconds / 60);
|
const m = Math.floor(seconds / 60);
|
||||||
const s = Math.floor(seconds % 60);
|
const s = Math.floor(seconds % 60);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
import VersionGraph from './components/VersionGraph.svelte';
|
import VersionGraph from './components/VersionGraph.svelte';
|
||||||
import ShareModal from './components/ShareModal.svelte';
|
import ShareModal from './components/ShareModal.svelte';
|
||||||
import CommentSection from './components/CommentSection.svelte';
|
import CommentSection from './components/CommentSection.svelte';
|
||||||
import StemList from './components/StemList.svelte';
|
import StemList, { type Stem } from './components/StemList.svelte';
|
||||||
|
|
||||||
type Version = {
|
type Version = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -86,7 +86,6 @@
|
|||||||
let branchFromId = $state<string | null>(null);
|
let branchFromId = $state<string | null>(null);
|
||||||
let branchLabelInput = $state('');
|
let branchLabelInput = $state('');
|
||||||
let shareOpen = $state(false);
|
let shareOpen = $state(false);
|
||||||
type Stem = { id: string; name: string; originalFileName: string; mimeType: string; fileSize: number; createdAt: string; createdById: string };
|
|
||||||
let stems = $state<Stem[]>([]);
|
let stems = $state<Stem[]>([]);
|
||||||
let panelTab = $state<'versions' | 'comments' | 'stems'>('versions');
|
let panelTab = $state<'versions' | 'comments' | 'stems'>('versions');
|
||||||
let panelOpen = $state(true);
|
let panelOpen = $state(true);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/api/client.js';
|
import { api } from '$lib/api/client.js';
|
||||||
import { toastSuccess } from '$lib/stores/toast.js';
|
import { toastSuccess } from '$lib/stores/toast.js';
|
||||||
|
import { formatFileSize } from '$lib/utils/format.js';
|
||||||
import Icon from '$lib/components/ui/Icon.svelte';
|
import Icon from '$lib/components/ui/Icon.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import StemUploadDropzone from '$lib/components/audio/StemUploadDropzone.svelte';
|
import StemUploadDropzone from '$lib/components/audio/StemUploadDropzone.svelte';
|
||||||
|
|
||||||
type Stem = {
|
export type Stem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
originalFileName: string;
|
originalFileName: string;
|
||||||
@@ -32,11 +33,6 @@
|
|||||||
let showUpload = $state(false);
|
let showUpload = $state(false);
|
||||||
let deleting = $state<string | null>(null);
|
let deleting = $state<string | null>(null);
|
||||||
|
|
||||||
function formatSize(bytes: number) {
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStems() {
|
async function loadStems() {
|
||||||
const res = await api.get<{ stems: Stem[] }>(`/stems/track/${trackId}`);
|
const res = await api.get<{ stems: Stem[] }>(`/stems/track/${trackId}`);
|
||||||
stems = res.stems;
|
stems = res.stems;
|
||||||
@@ -105,7 +101,7 @@
|
|||||||
<span class="stem-icon"><Icon name="music" size={14} /></span>
|
<span class="stem-icon"><Icon name="music" size={14} /></span>
|
||||||
<div class="stem-info">
|
<div class="stem-info">
|
||||||
<span class="stem-name">{stem.name}</span>
|
<span class="stem-name">{stem.name}</span>
|
||||||
<span class="stem-meta">{stem.originalFileName} · {formatSize(stem.fileSize)}</span>
|
<span class="stem-meta">{stem.originalFileName} · {formatFileSize(stem.fileSize)}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if role === 'owner' || stem.createdById === currentUserId}
|
{#if role === 'owner' || stem.createdById === currentUserId}
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user