Full MVP: workspace layout, visual refresh, PWA, production deploy

Major changes since initial commit:

Schema: version branching (parentVersionId, branchLabel), share links,
guest comments, track status enum (sketch/in_progress/final/released),
track sections, cover art for projects and tracks.

API: 29+ endpoints — auth, projects, tracks, versions, comments, share
links (public + management), uploads (cover), activity feed, onboarding
demo seed. Email templates in German with brand styling.

Web: SvelteKit 5 workspace layout with persistent sidebar, breadcrumb
top-bar, collapsible right panel. SoundCloud-style waveform player with
round play button, avatar comment markers, keyboard shortcuts (Space/JKL/C).
Full German UI. Cover art with gradient fallback. Track status pills.
Activity feed dashboard. Welcome modal with demo-seed trigger. Landing
page with 7-section scroll layout. Login on /login. Public /listen/:token
page for guest feedback.

Visual: Inter Variable font, Magenta→Orange gradient accent, warm dark
neutrals, Lucide-style inline SVG icon set, spring animations on modals,
glass-effect toasts, responsive from 360px to 2560px+.

PWA: manifest, service worker, icons, iOS/Android installable.

Production: adapter-node, server-side API proxy hook, docker-compose with
Postgres + MinIO + auto-migration + health checks. Env example included.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Robin Choice
2026-04-10 11:47:48 +02:00
parent 4dc095463f
commit 8bf72c2482
78 changed files with 8216 additions and 1386 deletions

View File

@@ -4,7 +4,8 @@
"type": "module",
"scripts": {
"dev": "bun run --hot src/index.ts",
"build": "bun build src/index.ts --outdir dist --target bun"
"build": "bun build src/index.ts --outdir dist --target bun",
"seed": "bun run src/scripts/seed.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3",

View File

@@ -8,6 +8,9 @@ import { trackRoutes } from './routes/tracks.js';
import { versionRoutes } from './routes/versions.js';
import { commentRoutes } from './routes/comments.js';
import { shareRoutes } from './routes/share.js';
import { uploadRoutes } from './routes/uploads.js';
import { activityRoutes } from './routes/activity.js';
import { onboardingRoutes } from './routes/onboarding.js';
import type { AppEnv } from './types.js';
const db = createDb(process.env.DATABASE_URL!);
@@ -36,7 +39,10 @@ const app = new Hono<AppEnv>()
.route('/tracks', trackRoutes)
.route('/versions', versionRoutes)
.route('/comments', commentRoutes)
.route('/share', shareRoutes);
.route('/share', shareRoutes)
.route('/uploads', uploadRoutes)
.route('/activity', activityRoutes)
.route('/onboarding', onboardingRoutes);
const port = parseInt(process.env.PORT || '3000');
console.log(`Music Hub API running on port ${port}`);

View File

@@ -0,0 +1,159 @@
/**
* Lightweight demo seed used by the onboarding endpoint.
* Creates one project with one track and three versions, plus a comment and a share link.
*
* Audio source: a synthetic WAV is generated on demand via FFmpeg if no asset is found,
* so this works regardless of whether the rich seed assets exist on disk.
*/
import {
projects,
projectMembers,
tracks,
versions,
comments,
shareLinks,
type Database,
} from '@music-hub/db';
import { createUploadUrl } from '../storage/s3.js';
import { processVersion } from '../services/audio-processor.js';
const ASSET_DIR = '/tmp/musichub-seed';
async function getDemoAudio(name: string, frequency: number, duration: number): Promise<ArrayBuffer> {
const f = Bun.file(`${ASSET_DIR}/${name}`);
if (await f.exists()) return await f.arrayBuffer();
// Synthesize on the fly with ffmpeg
const tmp = `/tmp/musichub-onb-${crypto.randomUUID()}.wav`;
const proc = Bun.spawn([
'ffmpeg',
'-f', 'lavfi',
'-i', `sine=frequency=${frequency}:duration=${duration}`,
'-ac', '2', '-ar', '44100',
'-v', 'quiet',
'-y',
tmp,
]);
await proc.exited;
const bytes = await Bun.file(tmp).arrayBuffer();
await Bun.spawn(['rm', tmp]).exited;
return bytes;
}
async function uploadAudio(
bytes: ArrayBuffer,
projectId: string,
trackId: string,
versionId: string,
filename: string,
): Promise<string> {
const fileKey = `projects/${projectId}/tracks/${trackId}/versions/${versionId}/original/${filename}`;
const uploadUrl = await createUploadUrl(fileKey, 'audio/wav', bytes.byteLength);
const res = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'audio/wav' },
body: bytes,
});
if (!res.ok) throw new Error(`Audio upload failed: ${res.status}`);
return fileKey;
}
export async function createDemoProject(db: Database, userId: string): Promise<string> {
// Project
const [project] = await db
.insert(projects)
.values({
name: 'Mein erstes Projekt',
description: 'Ein kleines Demo-Projekt zum Ausprobieren. Du kannst es jederzeit löschen.',
createdById: userId,
})
.returning();
await db.insert(projectMembers).values({
projectId: project.id,
userId,
role: 'owner',
canUpload: true,
canComment: true,
canApprove: true,
});
// Track
const [track] = await db
.insert(tracks)
.values({
projectId: project.id,
name: 'Demo-Track',
description: 'Drei Versionen mit Comments und einer Variante.',
status: 'in_progress',
createdById: userId,
})
.returning();
// Versions
const versionDefs = [
{ label: 'Erster Wurf', frequency: 220, duration: 8, file: 'demo-v1.wav' },
{ label: 'Mehr Bass', frequency: 110, duration: 8, file: 'demo-v2.wav' },
{ label: 'Final Mix', frequency: 330, duration: 8, file: 'demo-v3.wav' },
];
const createdVersionIds: string[] = [];
for (let i = 0; i < versionDefs.length; i++) {
const v = versionDefs[i];
const versionId = crypto.randomUUID();
const bytes = await getDemoAudio(v.file, v.frequency, v.duration);
const fileKey = await uploadAudio(bytes, project.id, track.id, versionId, v.file);
const [version] = await db
.insert(versions)
.values({
id: versionId,
trackId: track.id,
versionNumber: i + 1,
label: v.label,
status: 'uploaded',
originalFileName: v.file,
mimeType: 'audio/wav',
fileSize: bytes.byteLength,
originalFileKey: fileKey,
createdById: userId,
})
.returning();
createdVersionIds.push(version.id);
// Process synchronously so the player has a waveform when the user lands.
await processVersion(db, version.id);
}
// Comments on V2
await db.insert(comments).values([
{
versionId: createdVersionIds[1],
userId,
body: 'Klick auf die Welle bei einer beliebigen Stelle, um einen Kommentar mit Timestamp zu schreiben.',
timestampSeconds: 2.5,
},
{
versionId: createdVersionIds[1],
userId: null,
guestName: 'Demo-Listener',
body: 'So sieht es aus, wenn jemand ohne Account über einen geteilten Link kommentiert.',
timestampSeconds: 5.0,
},
]);
// Share link on V3
const token = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) =>
b.toString(16).padStart(2, '0'),
).join('');
await db.insert(shareLinks).values({
versionId: createdVersionIds[2],
token,
createdById: userId,
allowComments: true,
allowDownload: false,
});
return project.id;
}

View File

@@ -0,0 +1,116 @@
import { Hono } from 'hono';
import { sql } from 'drizzle-orm';
import { requireAuth } from '../middleware/auth.js';
import type { AppEnv } from '../types.js';
type ActivityEvent = {
type: 'comment' | 'version' | 'approval';
id: string;
createdAt: string;
user: { id: string | null; name: string; avatarUrl: string | null } | null;
guestName: string | null;
project: { id: string; name: string };
track: { id: string; name: string };
version?: { id: string; versionNumber: number; label: string | null };
body?: string;
status?: string;
timestampSeconds?: number | null;
};
export const activityRoutes = new Hono<AppEnv>()
.use('*', requireAuth)
.get('/', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const limit = Math.min(parseInt(c.req.query('limit') ?? '40'), 100);
const projectFilter = c.req.query('projectId');
// Helper: only events from projects the user is a member of.
// We use raw sql for the UNION since drizzle's union builder is per-table-shape.
const projectClause = projectFilter ? sql`AND p.id = ${projectFilter}` : sql``;
const rows = await db.execute(sql`
WITH membership AS (
SELECT project_id FROM project_members WHERE user_id = ${userId}
)
SELECT * FROM (
-- New versions
SELECT
'version' as type,
v.id as event_id,
v.created_at as created_at,
u.id as user_id,
u.name as user_name,
u.avatar_url as user_avatar,
NULL::text as guest_name,
p.id as project_id,
p.name as project_name,
t.id as track_id,
t.name as track_name,
v.id as version_id,
v.version_number as version_number,
v.label as version_label,
NULL::text as body,
v.status::text as status,
NULL::real as timestamp_seconds
FROM versions v
JOIN tracks t ON t.id = v.track_id
JOIN projects p ON p.id = t.project_id
JOIN membership m ON m.project_id = p.id
LEFT JOIN users u ON u.id = v.created_by_id
WHERE p.is_archived = false ${projectClause}
UNION ALL
-- New comments
SELECT
'comment' as type,
c.id as event_id,
c.created_at as created_at,
u.id as user_id,
u.name as user_name,
u.avatar_url as user_avatar,
c.guest_name as guest_name,
p.id as project_id,
p.name as project_name,
t.id as track_id,
t.name as track_name,
v.id as version_id,
v.version_number as version_number,
v.label as version_label,
c.body as body,
NULL::text as status,
c.timestamp_seconds as timestamp_seconds
FROM comments c
JOIN versions v ON v.id = c.version_id
JOIN tracks t ON t.id = v.track_id
JOIN projects p ON p.id = t.project_id
JOIN membership m ON m.project_id = p.id
LEFT JOIN users u ON u.id = c.user_id
WHERE p.is_archived = false ${projectClause}
) events
ORDER BY created_at DESC
LIMIT ${limit}
`);
const events: ActivityEvent[] = (rows as unknown as any[]).map((r) => ({
type: r.type as ActivityEvent['type'],
id: r.event_id,
createdAt: typeof r.created_at === 'string' ? r.created_at : new Date(r.created_at).toISOString(),
user: r.user_id
? { id: r.user_id, name: r.user_name, avatarUrl: r.user_avatar }
: null,
guestName: r.guest_name ?? null,
project: { id: r.project_id, name: r.project_name },
track: { id: r.track_id, name: r.track_name },
version: r.version_id
? { id: r.version_id, versionNumber: r.version_number, label: r.version_label }
: undefined,
body: r.body ?? undefined,
status: r.status ?? undefined,
timestampSeconds: r.timestamp_seconds ?? null,
}));
return c.json({ events });
});

View File

@@ -0,0 +1,29 @@
import { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import { projectMembers } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import { createDemoProject } from '../lib/demo-seed.js';
import type { AppEnv } from '../types.js';
export const onboardingRoutes = new Hono<AppEnv>()
.use('*', requireAuth)
.post('/seed-demo', async (c) => {
const db = c.get('db');
const userId = c.get('userId');
// Refuse to spam users with multiple demos
const existing = await db
.select({ id: projectMembers.id })
.from(projectMembers)
.where(eq(projectMembers.userId, userId))
.limit(1);
if (existing.length > 0) {
// User already has projects — they shouldn't see the welcome modal anyway,
// but be defensive: still create a fresh demo so the action is meaningful.
}
const projectId = await createDemoProject(db, userId);
return c.json({ projectId }, 201);
});

View File

@@ -1,16 +1,24 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { eq, and } from 'drizzle-orm';
import { eq, and, sql } from 'drizzle-orm';
import {
createProjectSchema,
updateProjectSchema,
inviteMemberSchema,
updateMemberSchema,
} from '@music-hub/shared';
import { projects, projectMembers, users } from '@music-hub/db';
import { projects, projectMembers, users, tracks } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import { createDownloadUrl } from '../storage/s3.js';
import type { AppEnv } from '../types.js';
async function withCoverUrl<T extends { coverImageUrl?: string | null }>(
obj: T,
): Promise<T & { coverUrl: string | null }> {
const coverUrl = obj.coverImageUrl ? await createDownloadUrl(obj.coverImageUrl) : null;
return { ...obj, coverUrl };
}
export const projectRoutes = new Hono<AppEnv>()
.use('*', requireAuth)
@@ -22,12 +30,19 @@ export const projectRoutes = new Hono<AppEnv>()
.select({
project: projects,
role: projectMembers.role,
trackCount: sql<number>`(select count(*)::int from ${tracks} where ${tracks.projectId} = ${projects.id})`,
})
.from(projectMembers)
.innerJoin(projects, eq(projects.id, projectMembers.projectId))
.where(and(eq(projectMembers.userId, userId), eq(projects.isArchived, false)));
return c.json({ projects: memberships });
const enriched = await Promise.all(
memberships.map(async (m) => ({
...m,
project: await withCoverUrl(m.project),
})),
);
return c.json({ projects: enriched });
})
.post('/', zValidator('json', createProjectSchema), async (c) => {
@@ -73,7 +88,7 @@ export const projectRoutes = new Hono<AppEnv>()
.where(eq(projects.id, projectId))
.limit(1);
return c.json({ project, role: membership.role });
return c.json({ project: await withCoverUrl(project), role: membership.role });
})
.patch('/:id', zValidator('json', updateProjectSchema), async (c) => {

View File

@@ -1,9 +1,10 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { eq, and, asc } from 'drizzle-orm';
import { eq, and, asc, sql } from 'drizzle-orm';
import { createTrackSchema, updateTrackSchema } from '@music-hub/shared';
import { tracks, projectMembers } from '@music-hub/db';
import { tracks, projectMembers, versions } from '@music-hub/db';
import { requireAuth } from '../middleware/auth.js';
import { createDownloadUrl } from '../storage/s3.js';
import type { AppEnv } from '../types.js';
export const trackRoutes = new Hono<AppEnv>()
@@ -24,12 +25,32 @@ export const trackRoutes = new Hono<AppEnv>()
if (!membership) return c.json({ error: 'Not found' }, 404);
const projectTracks = await db
.select()
.select({
id: tracks.id,
projectId: tracks.projectId,
name: tracks.name,
description: tracks.description,
coverImageUrl: tracks.coverImageUrl,
status: tracks.status,
section: tracks.section,
sortOrder: tracks.sortOrder,
createdById: tracks.createdById,
createdAt: tracks.createdAt,
updatedAt: tracks.updatedAt,
versionCount: sql<number>`(select count(*)::int from ${versions} where ${versions.trackId} = ${tracks.id})`,
branchCount: sql<number>`(select count(distinct ${versions.branchLabel})::int from ${versions} where ${versions.trackId} = ${tracks.id} and ${versions.branchLabel} is not null)`,
})
.from(tracks)
.where(eq(tracks.projectId, projectId))
.orderBy(asc(tracks.sortOrder), asc(tracks.createdAt));
return c.json({ tracks: projectTracks });
const enriched = await Promise.all(
projectTracks.map(async (t) => ({
...t,
coverUrl: t.coverImageUrl ? await createDownloadUrl(t.coverImageUrl) : null,
})),
);
return c.json({ tracks: enriched });
})
.post('/:projectId', zValidator('json', createTrackSchema), async (c) => {

View File

@@ -0,0 +1,17 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { coverUploadSchema } from '@music-hub/shared';
import { requireAuth } from '../middleware/auth.js';
import { createUploadUrl } from '../storage/s3.js';
import type { AppEnv } from '../types.js';
export const uploadRoutes = new Hono<AppEnv>()
.use('*', requireAuth)
.post('/cover', zValidator('json', coverUploadSchema), async (c) => {
const { fileName, mimeType, fileSize } = c.req.valid('json');
const ext = fileName.split('.').pop()?.toLowerCase() || 'jpg';
const key = `covers/${crypto.randomUUID()}.${ext}`;
const uploadUrl = await createUploadUrl(key, mimeType, fileSize);
return c.json({ uploadUrl, key });
});

View File

@@ -1,7 +1,7 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { eq, and, desc, asc, sql } from 'drizzle-orm';
import { requestUploadUrlSchema, createVersionSchema } from '@music-hub/shared';
import { requestUploadUrlSchema, createVersionSchema, updateVersionSchema } 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';
@@ -115,6 +115,82 @@ export const versionRoutes = new Hono<AppEnv>()
return c.json({ version }, 201);
})
// Update label/notes/branchLabel
.patch('/:id', zValidator('json', updateVersionSchema), async (c) => {
const db = c.get('db');
const userId = c.get('userId');
const versionId = c.req.param('id');
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.canUpload) {
return c.json({ error: 'Forbidden' }, 403);
}
const [updated] = await db
.update(versions)
.set(input)
.where(eq(versions.id, versionId))
.returning();
return c.json({ version: updated });
})
// Delete version
.delete('/:id', 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.role !== 'owner') {
return c.json({ error: 'Forbidden' }, 403);
}
await db.delete(versions).where(eq(versions.id, versionId));
return c.json({ message: 'Version deleted' });
})
// Get version tree (graph) for a track
.get('/track/:trackId/tree', async (c) => {
const db = c.get('db');

View File

@@ -0,0 +1,391 @@
/**
* Reicher Demo-Seed: 4 Projekte mit verschiedenen Charakteren, mehreren Tracks,
* Versionen, Mitgliedern, Comments und Share-Links.
*
* Voraussetzung: Audio-Dateien und Cover-PNGs liegen in /tmp/musichub-seed/
* bass.wav drums.wav hi.wav lead.wav mix.wav pad.wav vox.wav warm.wav
* sunset.png lichtjahre.png rooftop.png wave.png
*
* Usage:
* bun run apps/api/src/scripts/seed-rich.ts <email>
*/
import { eq } from 'drizzle-orm';
import {
createDb,
users,
projects,
projectMembers,
tracks,
versions,
comments,
shareLinks,
} from '@music-hub/db';
import { createUploadUrl } from '../storage/s3.js';
import { processVersion } from '../services/audio-processor.js';
const email = process.argv[2];
if (!email) {
console.error('Usage: bun run seed-rich.ts <email>');
process.exit(1);
}
const ASSET_DIR = '/tmp/musichub-seed';
async function readBytes(name: string): Promise<ArrayBuffer> {
const f = Bun.file(`${ASSET_DIR}/${name}`);
if (!(await f.exists())) throw new Error(`Missing asset: ${name}`);
return await f.arrayBuffer();
}
const db = createDb(process.env.DATABASE_URL!);
// === USER SETUP ===
console.log(`→ User ${email}`);
let [me] = await db.select().from(users).where(eq(users.email, email)).limit(1);
if (!me) {
[me] = await db.insert(users).values({ email, name: email.split('@')[0] }).returning();
console.log(` created ${me.id}`);
} else {
console.log(` exists ${me.id}`);
}
// Co-Mitglieder
const memberDefs = [
{ email: 'anna@example.com', name: 'Anna Berger', role: 'artist' as const },
{ email: 'jonas@example.com', name: 'Jonas Klein', role: 'mixing_engineer' as const },
{ email: 'lina@example.com', name: 'Lina Roth', role: 'mastering_engineer' as const },
{ email: 'felix@example.com', name: 'Felix Lang', role: 'label' as const },
];
const collaborators: Record<string, typeof users.$inferSelect> = {};
for (const m of memberDefs) {
let [u] = await db.select().from(users).where(eq(users.email, m.email)).limit(1);
if (!u) [u] = await db.insert(users).values({ email: m.email, name: m.name }).returning();
collaborators[m.email] = u;
}
console.log(`${memberDefs.length} Mitwirkende vorbereitet`);
// === COVER UPLOAD HELPER ===
async function uploadCover(filename: string): Promise<string> {
const bytes = await readBytes(filename);
const key = `covers/${crypto.randomUUID()}.png`;
const url = await createUploadUrl(key, 'image/png', bytes.byteLength);
const res = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'image/png' },
body: bytes,
});
if (!res.ok) throw new Error(`Cover upload failed: ${res.status}`);
return key;
}
// === PROJECT FACTORY ===
type TrackSpec = {
name: string;
description?: string;
versions: VersionSpec[];
comments?: CommentSpec[];
};
type VersionSpec = {
label: string;
audio: string;
status?: 'ready' | 'approved' | 'rejected';
parentLabel?: string;
branchLabel?: string;
notes?: string;
createdBy?: string;
};
type CommentSpec = {
versionLabel: string;
body: string;
at?: number;
by?: string; // email or 'guest:Name'
};
type ProjectSpec = {
name: string;
description: string;
cover: string;
members: { email: string; role: any }[];
tracks: TrackSpec[];
shareVersionLabel?: { trackName: string; versionLabel: string };
};
async function createProject(spec: ProjectSpec) {
console.log(`\n📀 ${spec.name}`);
const coverKey = await uploadCover(spec.cover);
const [project] = await db
.insert(projects)
.values({
name: spec.name,
description: spec.description,
coverImageUrl: coverKey,
createdById: me.id,
})
.returning();
await db.insert(projectMembers).values({
projectId: project.id,
userId: me.id,
role: 'owner',
canUpload: true,
canComment: true,
canApprove: true,
});
for (const m of spec.members) {
const u = collaborators[m.email];
if (!u) continue;
await db.insert(projectMembers).values({
projectId: project.id,
userId: u.id,
role: m.role,
canUpload: m.role.includes('engineer') || m.role === 'owner',
canComment: true,
canApprove: m.role === 'artist' || m.role === 'label' || m.role === 'management',
});
}
let shareToken: string | null = null;
for (const trackSpec of spec.tracks) {
const [track] = await db
.insert(tracks)
.values({
projectId: project.id,
name: trackSpec.name,
description: trackSpec.description,
createdById: me.id,
})
.returning();
console.log(` 🎵 ${trackSpec.name}`);
// Versionen in Reihenfolge anlegen, parent-Lookup über Label
const versionMap = new Map<string, string>();
for (let i = 0; i < trackSpec.versions.length; i++) {
const v = trackSpec.versions[i];
const audioBytes = await readBytes(v.audio);
const versionId = crypto.randomUUID();
const fileName = `${v.audio}`;
const fileKey = `projects/${project.id}/tracks/${track.id}/versions/${versionId}/original/${fileName}`;
const uploadUrl = await createUploadUrl(fileKey, 'audio/wav', audioBytes.byteLength);
const res = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'audio/wav' },
body: audioBytes,
});
if (!res.ok) throw new Error(`Audio upload failed: ${res.status}`);
const parentVersionId = v.parentLabel ? versionMap.get(v.parentLabel) ?? null : null;
const createdById = v.createdBy
? collaborators[v.createdBy]?.id ?? me.id
: me.id;
const [version] = await db
.insert(versions)
.values({
id: versionId,
trackId: track.id,
versionNumber: i + 1,
label: v.label,
notes: v.notes,
status: 'uploaded',
parentVersionId,
branchLabel: v.branchLabel ?? null,
originalFileName: fileName,
mimeType: 'audio/wav',
fileSize: audioBytes.byteLength,
originalFileKey: fileKey,
createdById,
})
.returning();
versionMap.set(v.label, version.id);
console.log(` V${i + 1} ${v.label}${v.branchLabel ? ` (Variante: ${v.branchLabel})` : ''}`);
await processVersion(db, version.id);
if (v.status && v.status !== 'ready') {
await db
.update(versions)
.set({ status: v.status })
.where(eq(versions.id, version.id));
}
}
// Comments
for (const c of trackSpec.comments ?? []) {
const versionId = versionMap.get(c.versionLabel);
if (!versionId) continue;
const isGuest = c.by?.startsWith('guest:');
const userId = isGuest
? null
: c.by
? collaborators[c.by]?.id ?? me.id
: me.id;
const guestName = isGuest ? c.by!.slice(6) : null;
await db.insert(comments).values({
versionId,
userId,
guestName,
body: c.body,
timestampSeconds: c.at ?? null,
});
}
// Share-Link auf eine bestimmte Version
if (
spec.shareVersionLabel &&
spec.shareVersionLabel.trackName === trackSpec.name
) {
const versionId = versionMap.get(spec.shareVersionLabel.versionLabel);
if (versionId) {
const token = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) =>
b.toString(16).padStart(2, '0'),
).join('');
await db.insert(shareLinks).values({
versionId,
token,
createdById: me.id,
allowComments: true,
allowDownload: false,
});
shareToken = token;
}
}
}
if (shareToken) {
console.log(` 🔗 Share: http://localhost:5173/listen/${shareToken}`);
}
}
// === PROJEKT-KATALOG ===
await createProject({
name: 'Sunset Drive',
description: 'Synthwave-EP. Mainline ist V3, V4 ist eine Variante mit anderem Bass.',
cover: 'sunset.png',
members: [
{ email: 'anna@example.com', role: 'artist' },
{ email: 'jonas@example.com', role: 'mixing_engineer' },
{ email: 'lina@example.com', role: 'mastering_engineer' },
],
tracks: [
{
name: 'Hauptmix',
description: 'Der zentrale Track der EP. Drei Iterationen plus eine Vocal-Variante.',
versions: [
{ label: 'Erster Wurf', audio: 'pad.wav' },
{ label: 'Mehr Bass', audio: 'bass.wav', notes: 'Sub etwas runter, Punch nach vorne' },
{ label: 'Final Mix', audio: 'mix.wav', status: 'approved', notes: 'Freigegeben von Lina nach Mastering' },
{ label: 'Andere Vocals', audio: 'vox.wav', parentLabel: 'Mehr Bass', branchLabel: 'vocals-alt', notes: 'Variante mit dem zweiten Vocal-Take', createdBy: 'jonas@example.com' },
],
comments: [
{ versionLabel: 'Mehr Bass', at: 3.2, by: 'anna@example.com', body: 'Hier ist es nice — aber bei 0:05 fehlt mir noch was im Mid-Range.' },
{ versionLabel: 'Mehr Bass', at: 5.5, body: 'Verstanden, ziehe ich nach. EQ-Boost bei 800Hz.' },
{ versionLabel: 'Final Mix', at: 1.8, by: 'lina@example.com', body: 'Master sitzt. -1 LUFS, dynamic range bleibt erhalten.' },
{ versionLabel: 'Andere Vocals', at: 2.0, by: 'anna@example.com', body: 'Yes! Das ist die richtige Energie für den Refrain.' },
],
},
{
name: 'Drums',
description: 'Drum-Bus für den Hauptmix.',
versions: [
{ label: 'Punchy', audio: 'drums.wav', createdBy: 'jonas@example.com' },
{ label: 'Mehr Air', audio: 'hi.wav', notes: 'Top-Hi-Hats lauter, Reverb-Tail dazu' },
],
comments: [
{ versionLabel: 'Punchy', at: 1.5, by: 'anna@example.com', body: 'Snare ist perfekt hier.' },
],
},
{
name: 'Lead Synth',
description: 'Hauptmotiv. Sitzt zwischen Vox und Pad.',
versions: [
{ label: 'V1', audio: 'lead.wav' },
],
},
],
shareVersionLabel: { trackName: 'Hauptmix', versionLabel: 'Final Mix' },
});
await createProject({
name: 'Lichtjahre',
description: 'Indie-Single. Acoustic-Demo, dann Studio-Take, dann Mix.',
cover: 'lichtjahre.png',
members: [
{ email: 'anna@example.com', role: 'artist' },
{ email: 'felix@example.com', role: 'label' },
],
tracks: [
{
name: 'Lichtjahre',
description: 'Hauptsong der Single.',
versions: [
{ label: 'Acoustic Demo', audio: 'warm.wav', notes: 'Aufgenommen am Küchentisch' },
{ label: 'Studio Take', audio: 'pad.wav', notes: 'Voller Mix mit Band' },
{ label: 'Mix V1', audio: 'mix.wav', createdBy: 'jonas@example.com' },
],
comments: [
{ versionLabel: 'Studio Take', at: 4.0, by: 'felix@example.com', body: 'Geht in die Richtung. Bei 0:08 könnten wir noch ein Break einbauen.' },
{ versionLabel: 'Studio Take', at: 7.2, body: 'Notiert.' },
{ versionLabel: 'Mix V1', at: 2.5, by: 'guest:Sarah (A&R)', body: 'Ich bin begeistert. Bei wann ist das fertig?' },
],
},
{
name: 'B-Side',
description: 'Instrumental-Version für Sync-Lizenz.',
versions: [
{ label: 'Instrumental', audio: 'pad.wav' },
],
},
],
shareVersionLabel: { trackName: 'Lichtjahre', versionLabel: 'Mix V1' },
});
await createProject({
name: 'Rooftop Session',
description: 'Live-Aufnahme vom letzten Sommer-Gig. Soundcheck + ein Take pro Song.',
cover: 'rooftop.png',
members: [
{ email: 'jonas@example.com', role: 'recording_engineer' },
],
tracks: [
{
name: 'Soundcheck',
versions: [{ label: 'Take 1', audio: 'drums.wav', createdBy: 'jonas@example.com' }],
},
{
name: 'Opener',
versions: [
{ label: 'Live Take', audio: 'bass.wav' },
{ label: 'Roh-Mix', audio: 'mix.wav', notes: 'Schneller Mix für die Band-Doku' },
],
comments: [
{ versionLabel: 'Live Take', at: 6.0, by: 'jonas@example.com', body: 'Crowd-Geräusche bewusst drin gelassen.' },
],
},
{
name: 'Encore',
versions: [{ label: 'Live Take', audio: 'lead.wav' }],
},
],
});
await createProject({
name: 'Wave Goodbye',
description: 'Closing-Track für die EP. Noch früh in der Entwicklung.',
cover: 'wave.png',
members: [
{ email: 'anna@example.com', role: 'artist' },
],
tracks: [
{
name: 'Skizze',
versions: [{ label: 'V1', audio: 'pad.wav', notes: 'Erste Idee, sehr roh' }],
},
],
});
console.log('\n✅ Fertig.');
console.log(` Login als ${email} via Magic Link.`);
process.exit(0);

View File

@@ -0,0 +1,159 @@
/**
* Demo seed script.
*
* Usage:
* ffmpeg -f lavfi -i "sine=frequency=440:duration=8" -ac 2 /tmp/musichub-demo.wav
* bun run apps/api/src/scripts/seed.ts demo@example.com [/tmp/musichub-demo.wav]
*
* Creates user (if missing), a demo project with 1 track and 3 versions
* (V2 mainline, V3 as branch off V2), 2 sample comments and 1 share link.
*/
import { eq } from 'drizzle-orm';
import { createDb, users, projects, projectMembers, tracks, versions, comments, shareLinks } from '@music-hub/db';
import { createUploadUrl } from '../storage/s3.js';
import { processVersion } from '../services/audio-processor.js';
const email = process.argv[2];
const audioPath = process.argv[3] || '/tmp/musichub-demo.wav';
if (!email) {
console.error('Usage: bun run seed.ts <email> [audioPath]');
process.exit(1);
}
const audioFile = Bun.file(audioPath);
if (!(await audioFile.exists())) {
console.error(`Audio file missing: ${audioPath}`);
console.error('Generate one first:');
console.error(` ffmpeg -f lavfi -i "sine=frequency=440:duration=8" -ac 2 ${audioPath}`);
process.exit(1);
}
const audioBytes = await audioFile.arrayBuffer();
const audioSize = audioBytes.byteLength;
const db = createDb(process.env.DATABASE_URL!);
console.log(`→ User ${email}`);
let [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);
if (!user) {
[user] = await db.insert(users).values({ email, name: email.split('@')[0] }).returning();
console.log(` created ${user.id}`);
} else {
console.log(` exists ${user.id}`);
}
console.log('→ Projekt "Demo: Sunset Drive"');
const [project] = await db
.insert(projects)
.values({
name: 'Demo: Sunset Drive',
description: 'Synthwave-Track in Arbeit. Probier den Graph, lade eine neue Variante hoch oder teile den Link.',
createdById: user.id,
})
.returning();
await db.insert(projectMembers).values({
projectId: project.id,
userId: user.id,
role: 'owner',
canUpload: true,
canComment: true,
canApprove: true,
});
console.log('→ Track "Hauptmix"');
const [track] = await db
.insert(tracks)
.values({
projectId: project.id,
name: 'Hauptmix',
description: 'Der Hauptmix mit zwei Mainline-Versionen und einer Vocals-Variante.',
createdById: user.id,
})
.returning();
async function uploadVersion(opts: {
versionNumber: number;
label: string;
parentVersionId?: string | null;
branchLabel?: string | null;
}) {
const versionId = crypto.randomUUID();
const fileName = `demo-v${opts.versionNumber}.wav`;
const fileKey = `projects/${project.id}/tracks/${track.id}/versions/${versionId}/original/${fileName}`;
const uploadUrl = await createUploadUrl(fileKey, 'audio/wav', audioSize);
const res = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'audio/wav' },
body: audioBytes,
});
if (!res.ok) throw new Error(`S3 upload failed: ${res.status} ${await res.text()}`);
const [version] = await db
.insert(versions)
.values({
id: versionId,
trackId: track.id,
versionNumber: opts.versionNumber,
label: opts.label,
status: 'uploaded',
parentVersionId: opts.parentVersionId ?? null,
branchLabel: opts.branchLabel ?? null,
originalFileName: fileName,
mimeType: 'audio/wav',
fileSize: audioSize,
originalFileKey: fileKey,
createdById: user.id,
})
.returning();
console.log(` V${opts.versionNumber} (${opts.branchLabel ?? 'main'}) → processing`);
await processVersion(db, version.id);
return version;
}
const v1 = await uploadVersion({ versionNumber: 1, label: 'Erster Wurf' });
const v2 = await uploadVersion({ versionNumber: 2, label: 'Mehr Bass' });
const v3 = await uploadVersion({
versionNumber: 3,
label: 'Vocals neu',
parentVersionId: v2.id,
branchLabel: 'vocals-neu',
});
console.log('→ Comments');
await db.insert(comments).values([
{
versionId: v2.id,
userId: user.id,
body: 'Bei 0:03 sitzt der Drop, finde ich richtig stark.',
timestampSeconds: 3.0,
},
{
versionId: v2.id,
userId: null,
guestName: 'Anna (Artist)',
body: 'Können wir die Vocals etwas weiter nach vorne ziehen?',
timestampSeconds: 5.5,
},
]);
console.log('→ Share-Link');
const token = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) =>
b.toString(16).padStart(2, '0'),
).join('');
await db.insert(shareLinks).values({
versionId: v2.id,
token,
createdById: user.id,
allowComments: true,
allowDownload: false,
});
console.log('\n✅ Done.');
console.log(` Login als ${email} via Magic Link.`);
console.log(` Share-Link: http://localhost:5173/listen/${token}`);
await new Promise((r) => setTimeout(r, 200));
process.exit(0);

View File

@@ -17,23 +17,23 @@ export async function sendMagicLinkEmail(email: string, token: string) {
await resend.emails.send({
from: fromEmail,
to: email,
subject: 'Your Music Hub Login Link',
subject: 'Dein Login-Link für 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;">Click the button below to log in:</p>
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 460px; margin: 0 auto; padding: 2.5rem 2rem; color: #f4f0ec; background: #0a0910;">
<h1 style="font-size: 1.6rem; margin: 0 0 1rem; background: linear-gradient(135deg, #f43f5e, #fb923c); -webkit-background-clip: text; background-clip: text; color: transparent; display: inline-block;">Music Hub</h1>
<p style="color: #9b96a8; line-height: 1.55; margin: 0 0 1.5rem;">Klick auf den Button um dich einzuloggen:</p>
<a href="${url}" style="
display: inline-block;
padding: 0.75rem 1.5rem;
background: #6366f1;
padding: 0.8rem 1.6rem;
background: linear-gradient(135deg, #f43f5e, #fb923c);
color: #fff;
border-radius: 8px;
border-radius: 10px;
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>
font-weight: 600;
margin: 0 0 1.5rem;
">Einloggen</a>
<p style="color: #5e596b; font-size: 0.85rem; margin: 0 0 0.5rem;">Der Link läuft in 15 Minuten ab.</p>
<p style="color: #5e596b; font-size: 0.85rem; margin: 0;">Wenn du das nicht angefordert hast, ignorier diese Mail einfach.</p>
</div>
`,
});
@@ -50,21 +50,20 @@ export async function sendInviteEmail(email: string, projectName: string, invite
await resend.emails.send({
from: fromEmail,
to: email,
subject: `You've been invited to "${projectName}" on Music Hub`,
subject: `${inviterName} hat dich zu "${projectName}" eingeladen`,
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>
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 460px; margin: 0 auto; padding: 2.5rem 2rem; color: #f4f0ec; background: #0a0910;">
<h1 style="font-size: 1.6rem; margin: 0 0 1rem; background: linear-gradient(135deg, #f43f5e, #fb923c); -webkit-background-clip: text; background-clip: text; color: transparent; display: inline-block;">Music Hub</h1>
<p style="color: #9b96a8; line-height: 1.55; margin: 0 0 1.5rem;"><strong style="color: #f4f0ec;">${inviterName}</strong> hat dich eingeladen, am Projekt <strong style="color: #f4f0ec;">"${projectName}"</strong> mitzuarbeiten.</p>
<a href="${url}" style="
display: inline-block;
padding: 0.75rem 1.5rem;
background: #6366f1;
padding: 0.8rem 1.6rem;
background: linear-gradient(135deg, #f43f5e, #fb923c);
color: #fff;
border-radius: 8px;
border-radius: 10px;
text-decoration: none;
font-weight: 500;
margin: 1rem 0;
">Open Music Hub</a>
font-weight: 600;
">Projekt öffnen</a>
</div>
`,
});