Versionsverwaltung für Musikproduktionen. Kein Chaos mehr in Dropbox-Ordnern voller final_v2_WIRKLICH_FINAL.wav.
Music Hub ist eine kollaborative Plattform für Produzenten, Engineers und Labels. Tracks haben Versionen, Versionen haben Kommentare, und alle Beteiligten sehen was gerade der aktuelle Stand ist — ohne dass jemand eine WAV-Datei per WhatsApp schickt.
Features:
- Projekte und Tracks mit vollständiger Versionshistorie
- Wellenform-Ansicht direkt im Browser (WaveSurfer.js)
- Zeitgestempelte Kommentare auf Versionen
- Share-Links für externe Reviewer (kein Account nötig)
- Ablieferung an Kunden mit Korrekturrunden und Bezahlwand (Lightning oder Überweisung direkt ans Studio)
- Magic-Link-Auth — kein Passwort-Reset-Chaos
- S3-kompatibler File-Storage (Hetzner Object Storage)
| Layer | Tech |
|---|---|
| Frontend | SvelteKit + TypeScript |
| Backend | Hono (Bun) |
| Datenbank | PostgreSQL + Drizzle ORM |
| Storage | S3-kompatibel (Hetzner / MinIO) |
| SMTP (z. B. Proton Mail) | |
| Deploy | Coolify, Docker |
Monorepo mit Turborepo: apps/web, apps/api, apps/cli, packages/db, packages/shared.
Voraussetzungen: Bun, Docker
# Dependencies
bun install
# Dienste starten (Postgres + MinIO)
docker compose up -d
# .env anlegen
cp .env.example .env
# Dev-Server
bun devWeb läuft auf http://localhost:5173, API auf http://localhost:3000.
Pflicht-Variablen in .env:
DATABASE_URL=postgresql://musichub:musichub@localhost:5435/musichub
# Salt der IP-Hashes in der Hör-Statistik (trotz des Namens), nicht wechseln
MAGIC_LINK_SECRET=<openssl rand -hex 32>
APP_URL=http://localhost:5173Für E-Mail-Versand (lokal optional, ohne stehen die Magic Links in der Konsole; in Produktion startet die API ohne SMTP_HOST nicht):
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=noreply@example.com
SMTP_PASS=...
EMAIL_FROM=Music Hub <noreply@example.com># Schema ändern → Migration generieren
bun db:generate
# Migration anwenden
bun db:migrateMusic Hub läuft auf jedem Server mit Docker. Du brauchst:
- eine Domain, z. B.
music.example.com - einen S3-kompatiblen Bucket mit HTTPS-Endpunkt, z. B. Hetzner Object Storage, Backblaze B2 oder Cloudflare R2. Browser laden Audio direkt in den Bucket hoch und streamen von dort.
- einen SMTP-Zugang für die Login-Mails. Ohne
SMTP_HOSTstartet die API nicht, sonst könnte jeder mit Zugriff auf die Logs sich mit den dort stehenden Login-Links als beliebiger Nutzer anmelden.
git clone https://github.com/robinchoice/music-hub.git
cd music-hub
cp .env.production.example .env # Werte eintragen
docker compose -f docker-compose.prod.yml up -d --buildDie App lauscht dann auf 127.0.0.1:3000. Davor gehört ein Reverse Proxy mit HTTPS, ohne HTTPS funktioniert der Login nicht. Mit Caddy reicht als Caddyfile:
music.example.com {
reverse_proxy 127.0.0.1:3000
}
Die API migriert die Datenbank beim Start und erlaubt der App-Domain den Zugriff auf den Bucket (CORS). Update: git pull, dann denselben docker compose-Befehl noch einmal. Sichern solltest du das Docker-Volume pgdata und den Bucket.
Läuft auf Coolify, gebaut aus robinchoice/music-hub (Branch main).
- Web (
Dockerfile.web): hub.pleasance.org, alte Domain hub.diespaetzles.lol parallel - API (
Dockerfile.api): intern viaAPI_INTERNAL_URL, führt Migrationen beim Start aus - Datenbank: Postgres als eigene Coolify-Ressource
Jeder Push auf main deployt automatisch: GitHub Actions prüft und baut die Images, dann deployt die CI API, Audio-Worker und Web über Coolify. Der Deploy ist erst erfolgreich, wenn /api/v1/health den Commit als revision meldet. Umgebungsvariablen werden in Coolify gepflegt, siehe .env.production.example.
Danach fotografiert der CI-Job screenshots die öffentliche Startseite (Demo mit Beispieldaten) und /login, ohne Anmeldung oder Nutzerdaten. apps/web/screenshots.ts erzeugt Desktop- (1440×900) und Handy-Bilder (390×844, doppelte Dichte) auf Deutsch und Englisch als WebP unter docs/screenshots. Der Bot committet geänderte Bilder mit [skip ci] auf main, damit kein weiterer Deploy startet. Anschließend meldet der Job prototype-deployed mit Repo-Slug und Deploy-Commit an robinchoice/pleasance über den bestehenden Empfänger-Workflow. Dafür nutzt er das bestehende Actions-Secret PLEASANCE_DISPATCH_TOKEN; dessen Token darf nur auf das Empfänger-Repo zugreifen.
Lokal: bun run screenshots [url] in apps/web, ohne URL gegen http://localhost:5173. Nur feste öffentliche Seiten ohne echte Nutzerdaten in PAGES aufnehmen, keine Profile, offenen Tracks oder Token-URLs. Vor dem nächsten Push den Bot-Commit von main holen.
Mails zu neuen Funktionen an alle, die sie bestellt haben. Ohne Opt-in geht nichts raus.
- Opt-in: ein nicht vorausgewähltes Kästchen beim Magic Link und bei der Registrierung. Es zählt erst, wenn der Link in der Mail geöffnet wird (Double-Opt-in),
users.updates_consent_athält den Zeitpunkt fest. Im Konto lässt es sich ein- und ausschalten. - Abbestellen: Link in jeder Update-Mail (
/abbestellen?liste=updates) und One-Click im Mailprogramm (List-Unsubscribe), beides mitusers.mail_tokenwie bei den Versions-Mails, aber unabhängig von deren Schalter. - Entwürfe: Agenten legen sie als
updates/<slug>.mdan, je Sprache# de: Titelbzw.# en: Title, darunter- Featureje Zeile, optionalaus demselben Ordner. Die API übernimmt jede Datei beim Start einmal als Entwurf, spätere Änderungen an der Datei nicht mehr. Der API-Test prüft, dass alle Dateien lesbar sind. - Senden: nur Admins aus
ADMIN_EMAILSunter/admin/updates, mit Vorschau und Testmail an sich selbst je Sprache. Gesendet wird erst mit Titel und Feature auf Deutsch und Englisch, jede Person bekommt die Sprache ihres Kontos (users.locale). Updates von vor Englisch (ohnecontent.en) lesen alle auf Deutsch. Danach lässt sich das Update nicht mehr ändern. - Versand: die API, eine Mail alle
UPDATE_MAIL_INTERVAL_MS(Standard 6000) über alle Instanzen, mit drei Versuchen pro Empfänger. Die Logs nennen keine Adressen.
Ein Studio hängt an seinen Link zum Reinhören für ein Projekt (/album/<token>) einen Auftrag: Kunde, Preis je Song, inklusive Korrekturrunden (Standard 2) und Preis jeder weiteren Runde. Im Projekt unter „Auftrag“, nur für Owner, oder per CLI musichub deliveries.
- Kunde: hört ohne Konto die MP3-Vorschau, kommentiert mit Zeitstempel und gibt je Song die gehörte Version frei oder fordert Änderungen an. Jede Anforderung ist eine Runde für diese Version, das Studio lädt danach eine neue hoch. Über die freien Runden hinaus muss der Kunde die Zusatzrunde ausdrücklich bestätigen („Zahlungspflichtig beauftragen · 40 €“), sie kommt auf die Schlussrechnung. Das Studio kann einem Song Runden schenken.
- Bezahlen: erst wenn alle Songs freigegeben sind, die Summe aus Songpreisen und Zusatzrunden, jede zum Preis, den der Kunde bestätigt hat. Schon eingegangene Zahlungen werden angerechnet, ein Auftrag ohne Betrag ist mit der letzten Freigabe bezahlt. Danach lädt der Kunde je Song die WAV der freigegebenen Version und, wenn eingestellt, die Spuren als ZIP. Vorher verlässt kein Original den Server.
- Kommt Geld für eine ersetzte oder abgelaufene Zahlung, zählt es trotzdem: Lightning-Rechnungen fragt die API noch zehn Minuten nach Ablauf bei der Wallet ab, jede andere Zahlung bestätigt das Studio im Auftrag von Hand. Zu viel Bezahltes zahlt das Studio selbst zurück.
- Geld läuft nie über Music Hub. Lightning: Rechnung aus der eigenen Wallet des Studios über Nostr Wallet Connect, nur mit Verbindungen, die empfangen, aber nicht zahlen dürfen; Kurs EUR→sats von mempool.space. Überweisung: an die IBAN des Studios mit GiroCode (EPC-QR), der Kunde meldet „Ich habe überwiesen“, das Studio bestätigt den Eingang. Zahlungswege richtet jedes Konto unter „Konto“ › „Zahlungswege“ ein.
- Tarif: Aufträge anlegen nur mit dem Tarif Studio (
users.plan = 'solo', optional bisplan_until) oder als Admin, siehe „Tarife“. - Server:
PAYMENT_SECRETS_KEY(32 Bytes base64,openssl rand -base64 32) verschlüsselt die Wallet-Verbindungen. Ohne ihn gibt es nur Überweisung. Ein neuer Key macht gespeicherte Verbindungen unlesbar, die Studios müssen sie neu eintragen.
Gratis, Pro (9 € im Monat, 90 € im Jahr, 100 GB) und Studio (24 € bzw. 240 €, 500 GB, dazu Aufträge). Preise und Grenzen stehen in packages/shared/src/constants/plans.ts, die Abrechnung in apps/api/src/lib/billing.ts, die Karte „Tarif“ im Konto in PlanSettings.svelte, in der CLI musichub plan.
- Regel „bezahlt bis“: Jede Zahlung schiebt nur
users.plan_until, egal woher das Geld kommt. Ein laufender Tarif hebt das Speicherlimit an, nie darunter (greatestaus eigenemstorage_limitund Tarif). Endet er, bleiben alle Dateien, nur neue Uploads über der eigenen Grenze gehen nicht mehr. Zahlende Konten zählen nicht gegenMAX_USERS. - Stripe (Karte, PayPal, SEPA): Checkout als Abo mit dem Preis aus
PLAN_PRICES, im Dashboard muss nichts angelegt werden. Der Webhookinvoice.paidsetztplan_untilaufs Ende der bezahlten Periode plus 3 Tage,customer.subscription.deletedvergisst nur das Abo. Kündigen über das Kundenportal von Stripe („Abo kündigen oder verwalten“). - Lightning: eine Rechnung unseres BTCPay Servers für einen Monat oder ein Jahr im Voraus, ohne Abo. Bezahlt schiebt sie
plan_untilum die Periode, bei laufendem Tarif ab dessen Ende. Der Webhook sagt nur, welche Rechnung; den Status fragt die API bei BTCPay ab, ebenso beim Öffnen des Kontos für offene Rechnungen des letzten Tages. Spät eingegangenes Geld zählt. - Während ein Tarif läuft, gibt es nur „mit Lightning verlängern“ desselben Tarifs. Wechsel und Kauf neben einem Abo oder einem Tarif ohne Ende (vom Admin) gehen nicht, das klärt der Admin im Lagebild. Werden doch zwei Rechnungen für verschiedene Tarife bezahlt, läuft der größere bis zum späteren Ende.
- Verbraucherrecht: Kaufen geht nur mit dem Häkchen „Ich verlange ausdrücklich, dass mein Tarif sofort beginnt …“ (
PLAN_CONSENT, gespeichert alsconsented_at). Jeder neue Vertrag (Lightning-Zahlung oder erste Rechnung eines Abos, nicht die Verlängerung) bekommt eine Bestätigungsmail mit AGB und Widerrufsbelehrung. „Verträge hier kündigen“ (§ 312k BGB) kündigt das Abo zum Ende der bezahlten Zeit, ein Jahresabo nach seiner ersten Verlängerung spätestens in einem Monat. „Widerrufen“ (§ 356a BGB) steht 14 Tage an der Zahlung und beendet, was sie gekauft hat. Erstattungen macht ein Admin von Hand, die API mailtADMIN_EMAILSBetrag und Rechnung. - Zahlungen stehen in
plan_payments(eine Zeile je Rechnung,external_ideindeutig, doppelte Webhooks ändern nichts) und bleiben bei gelöschtem Konto für die Buchhaltung. - Ohne die Variablen gibt es nichts zu kaufen, die Karte „Tarif“ erscheint dann nur bei laufendem Tarif. Einschalten:
- Vorher: Das Bucket-Backup darf nicht mehr über das Volume von VPS 1 laufen (AGENTS.md, „Fallen“). Robin gibt die AGB, die Widerrufsbelehrung und den Datenschutz-Abschnitt „13. Tarife und Zahlungen“ frei (
packages/shared/src/terms.ts, Entwurf nicht anwaltlich geprüft). Sie gehen mit dem ersten gesetzten Zahlungsweg live:/agb,/agb.md, der Abschnitt auf/datenschutzund die Bestätigungsmail. - Stripe:
STRIPE_SECRET_KEY(geheimer Key,sk_live_…) und einen Webhook aufhttps://hub.pleasance.org/api/v1/billing/stripemit den Ereignisseninvoice.paidundcustomer.subscription.deleted, dessen Signatur-Secret alsSTRIPE_WEBHOOK_SECRET. Kundenportal im Dashboard aktivieren (Kündigen erlauben). - BTCPay:
BTCPAY_URL(von VPS 1 aus erreichbar),BTCPAY_STORE_ID, einen Greenfield-API-Key mitbtcpay.store.cancreateinvoiceundbtcpay.store.canviewinvoicesalsBTCPAY_API_KEY, einen Webhook aufhttps://hub.pleasance.org/api/v1/billing/btcpay(Ereignis „Invoice settled“, gern alle Rechnungsereignisse) mit seinem Secret alsBTCPAY_WEBHOOK_SECRET. - Alle Werte nur in Coolify bei der API (
secret-to-coolify), nicht beim Worker. - In Stripe unter Rechnungen und Belegen den Hinweis „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.“ hinterlegen.
- Vorher: Das Bucket-Backup darf nicht mehr über das Volume von VPS 1 laufen (AGENTS.md, „Fallen“). Robin gibt die AGB, die Widerrufsbelehrung und den Datenschutz-Abschnitt „13. Tarife und Zahlungen“ frei (
Fremde Agenten lesen die öffentlichen Inhalte ohne HTML, nach llms.txt. Quelle ist publicPages in apps/api/src/lib/public-pages.ts, die Routen stehen in apps/api/src/routes/agents.ts. Der Web-Server leitet die Pfade an die API weiter.
/llms.txt: Einstieg mit Name, Tagline und allen öffentlichen Seiten als Links auf ihr Markdown./llms-full.txt: alle Seiten in einer Datei.<seite>.md: jede Seite als Markdown, die Startseite als/index.md. Fragt ein Agent mitAccept: text/markdownnach einer Seite, die es als Markdown gibt, bekommt er unter derselben URL gleich das Markdown, alle anderen das HTML.- Sprache:
?lang=de|en, sonstAccept-Language, sonst Deutsch. Links behalten ein?lang=. - Nur Startseite, Datenschutz und Impressum, nichts aus Projekten, offenen Tracks oder Profilen.
- Ausprobieren:
curl localhost:5173/llms.txtbei laufendembun run dev.
Das Repo ist öffentlich, Code, Historie und CI-Logs. Der Workflow .github/workflows/secrets.yml läuft bei jedem Push auf jedem Branch und bei PRs aus Forks, ohne paths-ignore, und scannt mit gitleaks (Docker-Image, Version per Digest fest) die neuen Commits. Ein Fund macht den Lauf rot, der Wert steht dank --redact nicht im Log.
- Regeln: die Standardregeln von gitleaks über
.gitleaks.toml, bisher ohne Ausnahmen. - Fehlalarm: den
Fingerprintaus dem Log als Zeile in.gitleaksignoreeintragen. - Echter Fund: Der Scan läuft erst nach dem Push, das Secret ist also schon öffentlich. Sofort rotieren, Entfernen aus dem Code oder der Historie reicht nicht.
Agenten zeigen Änderungen hinter dem Login in der Savor-Vorschau mit dem Testkonto admin@pleasance.org. Dafür gibt bun apps/api/src/login-link.ts <email> im API-Container einen einmaligen Login-Link aus, 15 Minuten gültig wie ein gemailter, den der Agent per browser_navigate öffnet. Browser-Sessions in Benutzung verlängern sich höchstens einmal am Tag auf 365 Tage, also bleibt die Vorschau angemeldet. Auch Sessions des Plugins gelten 365 Tage und verlängern sich gleitend. Für die CLI nimmt der Agent denselben Link per Pipe, siehe „CLI“.
Music Hub hat eine eigene Befehlszeile in apps/cli, damit Agenten, Skripte und Tests die App über Befehle bedienen statt über die UI. Sie spricht nur mit der HTTP-API wie Web und Plugin, nie mit der Datenbank. So gelten dieselben Rechte, Rate-Limits und MAX_USERS. Die Argumente liest parseArgs aus node:util, ohne weitere Abhängigkeit.
- Bauen:
bun run buildinapps/cli(auch Teil vonbun --bun run build) kompiliertapps/cli/build/musichub, eine einzelne Datei mit Bun darin (gut 100 MB). Die Adresse ist eingebacken,https://hub.pleasance.org, oderCLI_URLaus der Umgebung beim Bauen.--urlwählt eine andere, etwahttp://localhost:5173gegenbun run dev, danach gilt die Adresse des letzten Logins. Ohne Bauen:bun apps/cli/src/main.ts <befehl>. - Anmelden:
musichub loginnutzt den Geräte-Login des Plugins (routes/device.ts): Die CLI zeigt einen Link auf/devicemit Code, wer im Browser angemeldet ist, bestätigt ihn. Konten entstehen dabei nicht.musichub login --link <link>löst einen Login-Link ein,--link -liest ihn von stdin (/auth/verifymitcookie: falsegibt den Token zurück). Danach liegt der Token dieses Geräts mit0600in$XDG_CONFIG_HOME/musichub/session.json(sonst~/.config, unter macOS~/Library/Application Support, unter Windows%APPDATA%), zusammen mit der Adresse, an andere Adressen geht er nie. Er ist eine gewöhnliche Session als Bearer mitclient = 'cli': gehasht insessions, 365 Tage, verlängert sich bei Nutzung, jedes Gerät hat seine eigene.whoamizeigt das Konto,logoutwiderruft den Token auf dem Server und löscht die Datei, ein neuer Login widerruft den alten. - Befehle:
projects [--archived]listet die eigenen Projekte mit Rollen und Anzahl der Tracks.board [--project <Name oder ID>]listet die Tracks nach Produktionsphase wie das Board im Web,board move <Phase> --project <Name oder ID> --track <Name oder ID>schiebt einen in eine andere Phase (sketch,recording,mixing,mastering,final,released; nur wer hochladen darf).share --project <Name oder ID>gibt den eigenen Link zum Reinhören für ein ganzes Projekt aus und legt ihn beim ersten Mal an.deliveriesverwaltet Aufträge auf diesem Link, nur als Besitzer des Projekts:list [--project <Name oder ID>]listet sie mit Status, Freigaben und Summe,show <ID>zeigt Songs, Runden und Zahlungen.create --project <Name oder ID> --client <Name> --price <Euro>legt einen an (braucht den Tarif Studio), der Preis gilt je Song; dazu--track <Name>(mehrfach, sonst alle Songs),--email,--reference,--rounds(inklusive Runden je Song, Standard 2),--extra <Euro>(Zusatzrunde) und--no-stems.confirm-payment <ID>bestätigt den Eingang der offenen Überweisung,cancel <ID>storniert einen offenen Auftrag.planzeigt Tarif, Speicher und Zahlungen, gekauft wird im Web. Beträge nimmt die CLI in Euro wie40oder12,50. - Ausgabe: Text für Menschen, mit
--jsongenau ein JSON-Wert auf stdout, auch Fehler als{ "error": "…" }und die Hilfe als Liste der Befehle. Exit-Codes: 0 erledigt, 1 Fehler (auch Absagen der API wie ein volles Music Hub), 2 falscher Aufruf, 3 nicht angemeldet. Hilfe und Meldungen stehen auf Englisch inapps/cli/src/texts.ts, auch die API antwortet der CLI auf Englisch. - Eigene Befehle: Datei in
apps/cli/src/commandsnach dem Beispielprojects.tsund incommandsinsrc/cli.tseintragen. Ein Befehl nennt Hilfezeilen, Optionen undrun. Das ruft überctx.apidie API auf, liest die Antwort mit zod und gibt{ data, text }zurück,datafür--json.currentUser(ctx)verlangt den Login, Fehler enden alsFailuremit einem Code ausEXIT. Die Tests insrc/cli.test.tsrufen die CLI im Prozess gegen die echten API-Routen mit der Testdatenbank auf. - Agenten: nur mit dem Testkonto admin@pleasance.org, nie mit Kundenkonten. Den Token holen sie über den Login-Link aus dem Kontor, per Pipe, so steht der Link nicht in ihrem Kontext:
login-link musichub | apps/cli/build/musichub login --link -. Danach bleibt die CLI auf diesem Rechner angemeldet. - Verteilen: Für andere Plattformen
bun run build --target=bun-darwin-arm64inapps/cli(ebensobun-linux-x64,bun-linux-arm64,bun-windows-x64), das überschreibtbuild/musichub. Die CI baut die Datei bei jedem Lauf zur Probe, veröffentlicht sie aber nicht.
music-hub/
├── apps/
│ ├── api/ # Hono API (Bun)
│ ├── cli/ # Befehlszeile gegen die API, siehe „CLI“
│ └── web/ # SvelteKit Frontend
├── packages/
│ ├── db/ # Drizzle Schema + Migrationen
│ └── shared/ # Zod-Schemas, gemeinsame Typen
├── Dockerfile.api
├── Dockerfile.web
└── docker-compose.yml
bun run check && bun run test && bun --bun run build prüft Typen, die any-Regeln von Biome, API, CLI und Browser und baut Web und CLI. Die API- und Browser-Suite laufen nacheinander, damit Browser-Build und API-Tests auf kleinen Rechnern nicht konkurrieren. Jede Suite bekommt eine eigene leere Datenbank auf dem Server aus DATABASE_URL; der Nutzer braucht CREATEDB (lokaler Compose- und CI-Nutzer haben es). packages/db/test.ts entfernt nur diese temporäre Datenbank nach Erfolg, Fehler oder SIGINT/SIGTERM. Direkte Aufrufe von bun test oder playwright test umgehen die Isolation. Playwright nutzt 31100/31101 unter dem ephemeren Linux-Portbereich; parallele Worktrees wählen andere Ports unter 32768 per TEST_API_PORT und TEST_WEB_PORT.
SIGTERM/SIGINT lässt die API laufende Anfragen und Mailjobs beenden, schließt SSE und danach Postgres. Datei-Uploads laufen direkt nach S3; ihre Bestätigung kann der Client nach dem Neustart senden. Der Worker stoppt ffmpeg, bevor er den Job zur Wiederaufnahme freigibt. Compose gewährt beiden 60 Sekunden. Die Prozesse prüfen formatierte Env-Werte beim Start mit zod; Fehler nennen nur die Feldnamen.