Initial commit: Music Hub collaboration platform

Full-stack music production collaboration tool with:
- SvelteKit frontend with Design System (CSS vars, 8 shared components)
- Hono API with auth, projects, tracks, versions, comments
- PostgreSQL + Drizzle ORM (8 tables, roles, permissions)
- S3-compatible storage with presigned upload URLs
- wavesurfer.js audio player with waveform visualization
- A/B version comparison with synchronized playback
- Timestamped comments with threading and resolve workflow
- Magic Link authentication with Resend email integration
- Background audio processing (ffmpeg transcode + waveform peaks)
- Role-based access control (Owner, Engineers, Artist, Label, Management, Viewer)
- Toast notifications, skeleton loading, responsive layout
- Docker deployment setup (API + Web + Postgres)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Robin Choice
2026-04-02 13:23:10 +02:00
commit e420ed198b
88 changed files with 7306 additions and 0 deletions

45
apps/api/src/index.ts Normal file
View File

@@ -0,0 +1,45 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { createDb } from '@music-hub/db';
import { authRoutes } from './routes/auth.js';
import { projectRoutes } from './routes/projects.js';
import { trackRoutes } from './routes/tracks.js';
import { versionRoutes } from './routes/versions.js';
import { commentRoutes } from './routes/comments.js';
import type { AppEnv } from './types.js';
const db = createDb(process.env.DATABASE_URL!);
const app = new Hono<AppEnv>()
.use('*', logger())
.use(
'*',
cors({
origin: process.env.APP_URL || 'http://localhost:5173',
credentials: true,
}),
)
.use('*', async (c, next) => {
c.set('db', db);
await next();
})
.onError((err, c) => {
console.error('Unhandled error:', err);
return c.json({ error: err.message }, 500);
})
.get('/health', (c) => c.json({ status: 'ok' }))
.basePath('/api/v1')
.route('/auth', authRoutes)
.route('/projects', projectRoutes)
.route('/tracks', trackRoutes)
.route('/versions', versionRoutes)
.route('/comments', commentRoutes);
const port = parseInt(process.env.PORT || '3000');
console.log(`Music Hub API running on port ${port}`);
export default {
port,
fetch: app.fetch,
};

View File

@@ -0,0 +1,37 @@
import { createMiddleware } from 'hono/factory';
import { getCookie } from 'hono/cookie';
import { eq, gt } from 'drizzle-orm';
import { sessions } from '@music-hub/db';
import type { AppEnv } from '../types.js';
export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
const sessionToken = getCookie(c, 'session');
if (!sessionToken) {
return c.json({ error: 'Unauthorized' }, 401);
}
const tokenHash = await hashToken(sessionToken);
const db = c.get('db');
const [session] = await db
.select()
.from(sessions)
.where(eq(sessions.tokenHash, tokenHash))
.limit(1);
if (!session || session.expiresAt < new Date()) {
return c.json({ error: 'Unauthorized' }, 401);
}
c.set('userId', session.userId);
await next();
});
export async function hashToken(token: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(token);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

159
apps/api/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,159 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { setCookie, deleteCookie, getCookie } from 'hono/cookie';
import { eq } from 'drizzle-orm';
import { magicLinkSchema, verifyTokenSchema } from '@music-hub/shared';
import { users, magicLinks, sessions } from '@music-hub/db';
import { hashToken } from '../middleware/auth.js';
import { sendMagicLinkEmail } from '../services/email.js';
import type { AppEnv } from '../types.js';
export const authRoutes = new Hono<AppEnv>()
.post('/magic-link', zValidator('json', magicLinkSchema), async (c) => {
const { email } = c.req.valid('json');
const db = c.get('db');
const token = generateToken();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 min
await db.insert(magicLinks).values({
email,
token,
expiresAt,
});
await sendMagicLinkEmail(email, token);
return c.json({ message: 'Magic link sent' });
})
.post('/verify', zValidator('json', verifyTokenSchema), async (c) => {
const { token } = c.req.valid('json');
const db = c.get('db');
const [link] = await db
.select()
.from(magicLinks)
.where(eq(magicLinks.token, token))
.limit(1);
if (!link || link.expiresAt < new Date() || link.usedAt) {
return c.json({ error: 'Invalid or expired token' }, 400);
}
await db
.update(magicLinks)
.set({ usedAt: new Date() })
.where(eq(magicLinks.id, link.id));
// Find or create user
let [user] = await db
.select()
.from(users)
.where(eq(users.email, link.email))
.limit(1);
if (!user) {
const name = link.email.split('@')[0];
[user] = await db
.insert(users)
.values({ email: link.email, name })
.returning();
}
// Create session
const sessionToken = generateToken();
const tokenHash = await hashToken(sessionToken);
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
await db.insert(sessions).values({
userId: user.id,
tokenHash,
expiresAt,
});
setCookie(c, 'session', sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
path: '/',
maxAge: 30 * 24 * 60 * 60,
});
return c.json({ user: { id: user.id, email: user.email, name: user.name } });
})
.post('/logout', async (c) => {
const sessionToken = getCookie(c, 'session');
if (sessionToken) {
const db = c.get('db');
const tokenHash = await hashToken(sessionToken);
await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));
}
deleteCookie(c, 'session');
return c.json({ message: 'Logged out' });
})
.get('/me', async (c) => {
const sessionToken = getCookie(c, 'session');
if (!sessionToken) {
return c.json({ user: null });
}
const db = c.get('db');
const tokenHash = await hashToken(sessionToken);
const [session] = await db
.select()
.from(sessions)
.where(eq(sessions.tokenHash, tokenHash))
.limit(1);
if (!session || session.expiresAt < new Date()) {
return c.json({ user: null });
}
const [user] = await db
.select({ id: users.id, email: users.email, name: users.name, avatarUrl: users.avatarUrl })
.from(users)
.where(eq(users.id, session.userId))
.limit(1);
return c.json({ user: user || null });
})
.patch('/me', async (c) => {
const sessionToken = getCookie(c, 'session');
if (!sessionToken) return c.json({ error: 'Unauthorized' }, 401);
const db = c.get('db');
const tokenHash = await hashToken(sessionToken);
const [session] = await db
.select()
.from(sessions)
.where(eq(sessions.tokenHash, tokenHash))
.limit(1);
if (!session || session.expiresAt < new Date()) {
return c.json({ error: 'Unauthorized' }, 401);
}
const body = await c.req.json<{ name?: string }>();
if (!body.name?.trim()) return c.json({ error: 'Name is required' }, 400);
const [user] = await db
.update(users)
.set({ name: body.name.trim(), updatedAt: new Date() })
.where(eq(users.id, session.userId))
.returning({ id: users.id, email: users.email, name: users.name, avatarUrl: users.avatarUrl });
return c.json({ user });
});
function generateToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}

View File

@@ -0,0 +1,171 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { eq, and, asc } from 'drizzle-orm';
import { createCommentSchema, updateCommentSchema } from '@music-hub/shared';
import { comments, versions, tracks, projectMembers, users } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import type { AppEnv } from '../types.js';
export const commentRoutes = new Hono<AppEnv>()
.use('*', requireAuth)
// Get comments for a version
.get('/version/:versionId', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const versionId = c.req.param('versionId');
const [version] = await db
.select()
.from(versions)
.where(eq(versions.id, versionId))
.limit(1);
if (!version) return c.json({ error: 'Not found' }, 404);
const [track] = await db
.select()
.from(tracks)
.where(eq(tracks.id, version.trackId))
.limit(1);
const [membership] = await db
.select()
.from(projectMembers)
.where(
and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)),
)
.limit(1);
if (!membership) return c.json({ error: 'Not found' }, 404);
const versionComments = await db
.select({
id: comments.id,
body: comments.body,
timestampSeconds: comments.timestampSeconds,
parentId: comments.parentId,
resolvedAt: comments.resolvedAt,
createdAt: comments.createdAt,
user: {
id: users.id,
name: users.name,
avatarUrl: users.avatarUrl,
},
})
.from(comments)
.innerJoin(users, eq(users.id, comments.userId))
.where(eq(comments.versionId, versionId))
.orderBy(asc(comments.createdAt));
return c.json({ comments: versionComments });
})
// Create comment
.post(
'/version/:versionId',
zValidator('json', createCommentSchema),
async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const versionId = c.req.param('versionId');
const input = c.req.valid('json');
const [version] = await db
.select()
.from(versions)
.where(eq(versions.id, versionId))
.limit(1);
if (!version) return c.json({ error: 'Not found' }, 404);
const [track] = await db
.select()
.from(tracks)
.where(eq(tracks.id, version.trackId))
.limit(1);
const [membership] = await db
.select()
.from(projectMembers)
.where(
and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)),
)
.limit(1);
if (!membership || !membership.canComment) {
return c.json({ error: 'Forbidden' }, 403);
}
const [comment] = await db
.insert(comments)
.values({
versionId,
userId,
body: input.body,
timestampSeconds: input.timestampSeconds,
parentId: input.parentId,
})
.returning();
return c.json({ comment }, 201);
},
)
// Update comment
.patch('/:id', zValidator('json', updateCommentSchema), async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const commentId = c.req.param('id');
const input = c.req.valid('json');
const [comment] = await db
.select()
.from(comments)
.where(and(eq(comments.id, commentId), eq(comments.userId, userId)))
.limit(1);
if (!comment) return c.json({ error: 'Not found' }, 404);
const [updated] = await db
.update(comments)
.set({ body: input.body, updatedAt: new Date() })
.where(eq(comments.id, commentId))
.returning();
return c.json({ comment: updated });
})
// Delete comment
.delete('/:id', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const commentId = c.req.param('id');
const [comment] = await db
.select()
.from(comments)
.where(and(eq(comments.id, commentId), eq(comments.userId, userId)))
.limit(1);
if (!comment) return c.json({ error: 'Not found' }, 404);
await db.delete(comments).where(eq(comments.id, commentId));
return c.json({ message: 'Comment deleted' });
})
// Resolve comment
.post('/:id/resolve', async (c) => {
const db = c.get('db');
const commentId = c.req.param('id');
const [updated] = await db
.update(comments)
.set({ resolvedAt: new Date() })
.where(eq(comments.id, commentId))
.returning();
if (!updated) return c.json({ error: 'Not found' }, 404);
return c.json({ comment: updated });
});

View File

@@ -0,0 +1,294 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { eq, and } from 'drizzle-orm';
import {
createProjectSchema,
updateProjectSchema,
inviteMemberSchema,
updateMemberSchema,
} from '@music-hub/shared';
import { projects, projectMembers, users } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import type { AppEnv } from '../types.js';
export const projectRoutes = new Hono<AppEnv>()
.use('*', requireAuth)
.get('/', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const memberships = await db
.select({
project: projects,
role: projectMembers.role,
})
.from(projectMembers)
.innerJoin(projects, eq(projects.id, projectMembers.projectId))
.where(and(eq(projectMembers.userId, userId), eq(projects.isArchived, false)));
return c.json({ projects: memberships });
})
.post('/', zValidator('json', createProjectSchema), async (c) => {
const input = c.req.valid('json');
const db = c.get('db');
const userId = c.get('userId');
const [project] = await db
.insert(projects)
.values({ ...input, createdById: userId })
.returning();
await db.insert(projectMembers).values({
projectId: project.id,
userId,
role: 'owner',
canUpload: true,
canComment: true,
canApprove: true,
});
return c.json({ project }, 201);
})
.get('/:id', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const projectId = c.req.param('id');
const [membership] = await db
.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
.limit(1);
if (!membership) {
return c.json({ error: 'Not found' }, 404);
}
const [project] = await db
.select()
.from(projects)
.where(eq(projects.id, projectId))
.limit(1);
return c.json({ project, role: membership.role });
})
.patch('/:id', zValidator('json', updateProjectSchema), async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const projectId = c.req.param('id');
const input = c.req.valid('json');
const [membership] = await db
.select()
.from(projectMembers)
.where(
and(
eq(projectMembers.projectId, projectId),
eq(projectMembers.userId, userId),
eq(projectMembers.role, 'owner'),
),
)
.limit(1);
if (!membership) {
return c.json({ error: 'Forbidden' }, 403);
}
const [project] = await db
.update(projects)
.set({ ...input, updatedAt: new Date() })
.where(eq(projects.id, projectId))
.returning();
return c.json({ project });
})
.delete('/:id', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const projectId = c.req.param('id');
const [membership] = await db
.select()
.from(projectMembers)
.where(
and(
eq(projectMembers.projectId, projectId),
eq(projectMembers.userId, userId),
eq(projectMembers.role, 'owner'),
),
)
.limit(1);
if (!membership) {
return c.json({ error: 'Forbidden' }, 403);
}
await db
.update(projects)
.set({ isArchived: true, updatedAt: new Date() })
.where(eq(projects.id, projectId));
return c.json({ message: 'Project archived' });
})
// Members
.get('/:id/members', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const projectId = c.req.param('id');
// Check access
const [membership] = await db
.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
.limit(1);
if (!membership) {
return c.json({ error: 'Not found' }, 404);
}
const members = await db
.select({
id: projectMembers.id,
role: projectMembers.role,
canUpload: projectMembers.canUpload,
canComment: projectMembers.canComment,
canApprove: projectMembers.canApprove,
user: {
id: users.id,
email: users.email,
name: users.name,
avatarUrl: users.avatarUrl,
},
})
.from(projectMembers)
.innerJoin(users, eq(users.id, projectMembers.userId))
.where(eq(projectMembers.projectId, projectId));
return c.json({ members });
})
.post('/:id/members', zValidator('json', inviteMemberSchema), async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const projectId = c.req.param('id');
const { email, role } = c.req.valid('json');
// Check permission (owner or management)
const [membership] = await db
.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
.limit(1);
if (!membership || (membership.role !== 'owner' && membership.role !== 'management')) {
return c.json({ error: 'Forbidden' }, 403);
}
// Find or create user
let [invitedUser] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!invitedUser) {
[invitedUser] = await db
.insert(users)
.values({ email, name: email.split('@')[0] })
.returning();
}
const defaults = getRoleDefaults(role);
const [member] = await db
.insert(projectMembers)
.values({
projectId,
userId: invitedUser.id,
role,
...defaults,
})
.onConflictDoNothing()
.returning();
if (!member) {
return c.json({ error: 'User already a member' }, 409);
}
return c.json({ member }, 201);
})
.patch('/:id/members/:memberId', zValidator('json', updateMemberSchema), async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const projectId = c.req.param('id');
const memberId = c.req.param('memberId');
const { role: newRole } = c.req.valid('json');
const [membership] = await db
.select()
.from(projectMembers)
.where(
and(
eq(projectMembers.projectId, projectId),
eq(projectMembers.userId, userId),
eq(projectMembers.role, 'owner'),
),
)
.limit(1);
if (!membership) {
return c.json({ error: 'Forbidden' }, 403);
}
const defaults = getRoleDefaults(newRole);
const [updated] = await db
.update(projectMembers)
.set({ role: newRole, ...defaults })
.where(eq(projectMembers.id, memberId))
.returning();
return c.json({ member: updated });
})
.delete('/:id/members/:memberId', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const projectId = c.req.param('id');
const memberId = c.req.param('memberId');
const [membership] = await db
.select()
.from(projectMembers)
.where(
and(
eq(projectMembers.projectId, projectId),
eq(projectMembers.userId, userId),
eq(projectMembers.role, 'owner'),
),
)
.limit(1);
if (!membership) {
return c.json({ error: 'Forbidden' }, 403);
}
await db.delete(projectMembers).where(eq(projectMembers.id, memberId));
return c.json({ message: 'Member removed' });
});
function getRoleDefaults(role: string) {
const engineerRoles = ['recording_engineer', 'mixing_engineer', 'mastering_engineer'];
if (role === 'owner') return { canUpload: true, canComment: true, canApprove: true };
if (engineerRoles.includes(role)) return { canUpload: true, canComment: true, canApprove: false };
if (role === 'artist') return { canUpload: false, canComment: true, canApprove: true };
if (role === 'label') return { canUpload: false, canComment: true, canApprove: true };
if (role === 'management') return { canUpload: false, canComment: true, canApprove: true };
return { canUpload: false, canComment: false, canApprove: false }; // viewer
}

View File

@@ -0,0 +1,123 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { eq, and, asc } from 'drizzle-orm';
import { createTrackSchema, updateTrackSchema } from '@music-hub/shared';
import { tracks, projectMembers } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import type { AppEnv } from '../types.js';
export const trackRoutes = new Hono<AppEnv>()
.use('*', requireAuth)
// Get all tracks for a project
.get('/project/:projectId', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const projectId = c.req.param('projectId');
const [membership] = await db
.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
.limit(1);
if (!membership) return c.json({ error: 'Not found' }, 404);
const projectTracks = await db
.select()
.from(tracks)
.where(eq(tracks.projectId, projectId))
.orderBy(asc(tracks.sortOrder), asc(tracks.createdAt));
return c.json({ tracks: projectTracks });
})
.post('/:projectId', zValidator('json', createTrackSchema), async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const projectId = c.req.param('projectId');
const input = c.req.valid('json');
const [membership] = await db
.select()
.from(projectMembers)
.where(and(eq(projectMembers.projectId, projectId), eq(projectMembers.userId, userId)))
.limit(1);
if (!membership || !membership.canUpload) {
return c.json({ error: 'Forbidden' }, 403);
}
const [track] = await db
.insert(tracks)
.values({ ...input, projectId, createdById: userId })
.returning();
return c.json({ track }, 201);
})
.patch('/:id', zValidator('json', updateTrackSchema), async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const trackId = c.req.param('id');
const input = c.req.valid('json');
const [track] = await db
.select()
.from(tracks)
.where(eq(tracks.id, 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)))
.limit(1);
if (!membership || !membership.canUpload) {
return c.json({ error: 'Forbidden' }, 403);
}
const [updated] = await db
.update(tracks)
.set({ ...input, updatedAt: new Date() })
.where(eq(tracks.id, trackId))
.returning();
return c.json({ track: updated });
})
.delete('/:id', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const trackId = c.req.param('id');
const [track] = await db
.select()
.from(tracks)
.where(eq(tracks.id, 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),
eq(projectMembers.role, 'owner'),
),
)
.limit(1);
if (!membership) {
return c.json({ error: 'Forbidden' }, 403);
}
await db.delete(tracks).where(eq(tracks.id, trackId));
return c.json({ message: 'Track deleted' });
});

View File

@@ -0,0 +1,251 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { eq, and, desc, sql } from 'drizzle-orm';
import { requestUploadUrlSchema, createVersionSchema } from '@music-hub/shared';
import { tracks, versions, projectMembers } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import { createUploadUrl, createDownloadUrl } from '../storage/s3.js';
import { processVersion } from '../services/audio-processor.js';
import type { AppEnv } from '../types.js';
export const versionRoutes = new Hono<AppEnv>()
.use('*', requireAuth)
// Get all versions for a track
.get('/track/:trackId', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const trackId = c.req.param('trackId');
const [track] = await db.select().from(tracks).where(eq(tracks.id, 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)))
.limit(1);
if (!membership) return c.json({ error: 'Not found' }, 404);
const trackVersions = await db
.select()
.from(versions)
.where(eq(versions.trackId, trackId))
.orderBy(desc(versions.versionNumber));
return c.json({ versions: trackVersions });
})
// Request presigned upload URL
.post(
'/track/:trackId/upload-url',
zValidator('json', requestUploadUrlSchema),
async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const trackId = c.req.param('trackId');
const { fileName, mimeType, fileSize } = c.req.valid('json');
const [track] = await db.select().from(tracks).where(eq(tracks.id, 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)),
)
.limit(1);
if (!membership || !membership.canUpload) {
return c.json({ error: 'Forbidden' }, 403);
}
const versionId = crypto.randomUUID();
const fileKey = `projects/${track.projectId}/tracks/${trackId}/versions/${versionId}/original/${fileName}`;
const uploadUrl = await createUploadUrl(fileKey, mimeType, fileSize);
return c.json({ uploadUrl, fileKey, versionId });
},
)
// Register version after upload
.post('/track/:trackId', zValidator('json', createVersionSchema), async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const trackId = c.req.param('trackId');
const input = c.req.valid('json');
const [track] = await db.select().from(tracks).where(eq(tracks.id, trackId)).limit(1);
if (!track) return c.json({ error: 'Not found' }, 404);
// Get next version number
const [latest] = await db
.select({ maxVersion: sql<number>`coalesce(max(${versions.versionNumber}), 0)` })
.from(versions)
.where(eq(versions.trackId, trackId));
const versionNumber = (latest?.maxVersion ?? 0) + 1;
const [version] = await db
.insert(versions)
.values({
trackId,
versionNumber,
label: input.label,
notes: input.notes,
status: 'uploaded',
originalFileName: input.originalFileName,
mimeType: input.mimeType,
fileSize: input.fileSize,
originalFileKey: input.fileKey,
createdById: userId,
})
.returning();
// Background processing (fire and forget)
processVersion(db, version.id).catch((err) =>
console.error(`[Worker] Failed: ${err.message}`),
);
return c.json({ version }, 201);
})
// Get stream URL
.get('/:id/stream-url', async (c) => {
const db = c.get('db');
const versionId = c.req.param('id');
const [version] = await db
.select()
.from(versions)
.where(eq(versions.id, versionId))
.limit(1);
if (!version) return c.json({ error: 'Not found' }, 404);
const key = version.streamFileKey || version.originalFileKey;
const url = await createDownloadUrl(key);
return c.json({ url });
})
// Get download URL
.get('/:id/download-url', async (c) => {
const db = c.get('db');
const versionId = c.req.param('id');
const [version] = await db
.select()
.from(versions)
.where(eq(versions.id, versionId))
.limit(1);
if (!version) return c.json({ error: 'Not found' }, 404);
const url = await createDownloadUrl(version.originalFileKey);
return c.json({ url });
})
// Get waveform data
.get('/:id/waveform', async (c) => {
const db = c.get('db');
const versionId = c.req.param('id');
const [version] = await db
.select()
.from(versions)
.where(eq(versions.id, versionId))
.limit(1);
if (!version || !version.waveformDataKey) {
return c.json({ error: 'Not found' }, 404);
}
const url = await createDownloadUrl(version.waveformDataKey);
return c.json({ url });
})
// Approve version
.post('/:id/approve', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const versionId = c.req.param('id');
const [version] = await db
.select()
.from(versions)
.where(eq(versions.id, versionId))
.limit(1);
if (!version) return c.json({ error: 'Not found' }, 404);
const [track] = await db
.select()
.from(tracks)
.where(eq(tracks.id, version.trackId))
.limit(1);
const [membership] = await db
.select()
.from(projectMembers)
.where(
and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)),
)
.limit(1);
if (!membership || !membership.canApprove) {
return c.json({ error: 'Forbidden' }, 403);
}
const [updated] = await db
.update(versions)
.set({ status: 'approved' })
.where(eq(versions.id, versionId))
.returning();
return c.json({ version: updated });
})
// Reject version
.post('/:id/reject', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const versionId = c.req.param('id');
const [version] = await db
.select()
.from(versions)
.where(eq(versions.id, versionId))
.limit(1);
if (!version) return c.json({ error: 'Not found' }, 404);
const [track] = await db
.select()
.from(tracks)
.where(eq(tracks.id, version.trackId))
.limit(1);
const [membership] = await db
.select()
.from(projectMembers)
.where(
and(eq(projectMembers.projectId, track!.projectId), eq(projectMembers.userId, userId)),
)
.limit(1);
if (!membership || !membership.canApprove) {
return c.json({ error: 'Forbidden' }, 403);
}
const [updated] = await db
.update(versions)
.set({ status: 'rejected' })
.where(eq(versions.id, versionId))
.returning();
return c.json({ version: updated });
});

View File

@@ -0,0 +1,159 @@
import { eq } from 'drizzle-orm';
import { versions } from '@music-hub/db';
import { createUploadUrl, createDownloadUrl } from '../storage/s3.js';
import type { Database } from '@music-hub/db';
export async function processVersion(db: Database, versionId: string) {
console.log(`[Worker] Processing version ${versionId}`);
// Mark as processing
const [version] = await db
.update(versions)
.set({ status: 'processing' })
.where(eq(versions.id, versionId))
.returning();
if (!version) {
console.error(`[Worker] Version ${versionId} not found`);
return;
}
try {
const originalUrl = await createDownloadUrl(version.originalFileKey, 3600);
// Extract metadata with ffprobe
const metadata = await extractMetadata(originalUrl);
// Generate waveform peaks
const peaks = await generateWaveformPeaks(originalUrl, metadata.duration);
const waveformKey = version.originalFileKey.replace(/\/original\/.*$/, '/waveform/peaks.json');
// Upload waveform data to S3
const waveformUploadUrl = await createUploadUrl(waveformKey, 'application/json', 10 * 1024 * 1024);
await fetch(waveformUploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(peaks),
});
// Transcode to MP3 for streaming
const streamKey = version.originalFileKey.replace(/\/original\/.*$/, '/stream/audio.mp3');
await transcodeToMp3(originalUrl, streamKey);
// Update version with metadata
await db
.update(versions)
.set({
status: 'ready',
duration: metadata.duration,
sampleRate: metadata.sampleRate,
bitDepth: metadata.bitDepth,
streamFileKey: streamKey,
waveformDataKey: waveformKey,
})
.where(eq(versions.id, versionId));
console.log(`[Worker] Version ${versionId} ready`);
} catch (error) {
console.error(`[Worker] Failed to process version ${versionId}:`, error);
// Still mark as ready so user can listen to original
await db
.update(versions)
.set({ status: 'ready' })
.where(eq(versions.id, versionId));
}
}
async function extractMetadata(url: string): Promise<{
duration: number;
sampleRate: number;
bitDepth: number;
}> {
const proc = Bun.spawn([
'ffprobe',
'-v', 'quiet',
'-print_format', 'json',
'-show_format',
'-show_streams',
url,
]);
const output = await new Response(proc.stdout).text();
await proc.exited;
const data = JSON.parse(output);
const audioStream = data.streams?.find((s: any) => s.codec_type === 'audio');
return {
duration: parseFloat(data.format?.duration || '0'),
sampleRate: parseInt(audioStream?.sample_rate || '44100'),
bitDepth: parseInt(audioStream?.bits_per_raw_sample || audioStream?.bits_per_sample || '16'),
};
}
async function generateWaveformPeaks(url: string, duration: number): Promise<number[]> {
// Generate raw PCM samples with ffmpeg, then compute peaks
const samplesPerPixel = Math.max(1, Math.floor(duration * 44100 / 800)); // ~800 peaks
const proc = Bun.spawn([
'ffmpeg',
'-i', url,
'-ac', '1', // mono
'-ar', '8000', // low sample rate for peaks
'-f', 'f32le', // raw 32-bit float
'-v', 'quiet',
'pipe:1',
]);
const buffer = await new Response(proc.stdout).arrayBuffer();
await proc.exited;
const samples = new Float32Array(buffer);
const numPeaks = 800;
const blockSize = Math.max(1, Math.floor(samples.length / numPeaks));
const peaks: number[] = [];
for (let i = 0; i < numPeaks && i * blockSize < samples.length; i++) {
let max = 0;
const start = i * blockSize;
const end = Math.min(start + blockSize, samples.length);
for (let j = start; j < end; j++) {
const abs = Math.abs(samples[j]);
if (abs > max) max = abs;
}
peaks.push(Math.round(max * 1000) / 1000);
}
return peaks;
}
async function transcodeToMp3(inputUrl: string, outputKey: string) {
// Transcode to temp file, then upload
const tmpFile = `/tmp/musichub-${crypto.randomUUID()}.mp3`;
const proc = Bun.spawn([
'ffmpeg',
'-i', inputUrl,
'-codec:a', 'libmp3lame',
'-b:a', '128k',
'-v', 'quiet',
'-y',
tmpFile,
]);
await proc.exited;
// Upload to S3
const file = Bun.file(tmpFile);
const fileSize = file.size;
const uploadUrl = await createUploadUrl(outputKey, 'audio/mpeg', fileSize);
await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'audio/mpeg' },
body: file,
});
// Cleanup
await Bun.file(tmpFile).exists() && (await Bun.spawn(['rm', tmpFile]).exited);
}

View File

@@ -0,0 +1,71 @@
import { Resend } from 'resend';
const resend = process.env.RESEND_API_KEY
? new Resend(process.env.RESEND_API_KEY)
: null;
const fromEmail = process.env.EMAIL_FROM || 'Music Hub <noreply@musichub.de>';
export async function sendMagicLinkEmail(email: string, token: string) {
const url = `${process.env.APP_URL}/auth/verify?token=${token}`;
if (!resend) {
console.log(`[DEV] Magic link for ${email}: ${url}`);
return;
}
await resend.emails.send({
from: fromEmail,
to: email,
subject: 'Your Music Hub Login Link',
html: `
<div style="font-family: -apple-system, sans-serif; max-width: 400px; margin: 0 auto; padding: 2rem;">
<h1 style="font-size: 1.5rem; color: #f0f0f0;">Music Hub</h1>
<p style="color: #a0a0a0;">Click the button below to log in:</p>
<a href="${url}" style="
display: inline-block;
padding: 0.75rem 1.5rem;
background: #6366f1;
color: #fff;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
margin: 1rem 0;
">Log in to Music Hub</a>
<p style="color: #666; font-size: 0.85rem;">This link expires in 15 minutes.</p>
<p style="color: #666; font-size: 0.85rem;">If you didn't request this, ignore this email.</p>
</div>
`,
});
}
export async function sendInviteEmail(email: string, projectName: string, inviterName: string) {
const url = `${process.env.APP_URL}`;
if (!resend) {
console.log(`[DEV] Invite ${email} to project "${projectName}" by ${inviterName}`);
return;
}
await resend.emails.send({
from: fromEmail,
to: email,
subject: `You've been invited to "${projectName}" on Music Hub`,
html: `
<div style="font-family: -apple-system, sans-serif; max-width: 400px; margin: 0 auto; padding: 2rem;">
<h1 style="font-size: 1.5rem; color: #f0f0f0;">Music Hub</h1>
<p style="color: #a0a0a0;">${inviterName} invited you to collaborate on <strong>"${projectName}"</strong>.</p>
<a href="${url}" style="
display: inline-block;
padding: 0.75rem 1.5rem;
background: #6366f1;
color: #fff;
border-radius: 8px;
text-decoration: none;
font-weight: 500;
margin: 1rem 0;
">Open Music Hub</a>
</div>
`,
});
}

View File

@@ -0,0 +1,49 @@
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({
region: 'eu-central-1',
endpoint: process.env.S3_ENDPOINT,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
forcePathStyle: true,
});
const bucket = process.env.S3_BUCKET!;
export async function createUploadUrl(
key: string,
contentType: string,
maxSize: number,
): Promise<string> {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
ContentLength: maxSize,
});
return getSignedUrl(s3, command, { expiresIn: 900 }); // 15 min
}
export async function createDownloadUrl(key: string, expiresIn = 3600): Promise<string> {
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
return getSignedUrl(s3, command, { expiresIn });
}
export async function deleteObject(key: string): Promise<void> {
const command = new DeleteObjectCommand({
Bucket: bucket,
Key: key,
});
await s3.send(command);
}

8
apps/api/src/types.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { Database } from '@music-hub/db';
export type AppEnv = {
Variables: {
db: Database;
userId: string;
};
};