feat: add STEM file support per track

- DB: stems table with trackId FK, fileKey, sortOrder, createdById
- API: GET/POST/DELETE stems, presigned upload URL, ZIP download via fflate
- Web: StemUploadDropzone (multi-file, batch upload, progress bars)
- Web: StemList with download-all-ZIP and per-stem delete
- Web: STEMs tab in track detail view
- Icon: add 'music' icon to inline set
- Auto-migration runs stems table creation on boot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Choice
2026-04-13 18:13:01 +02:00
parent df54fde710
commit 9530add1ff
15 changed files with 1812 additions and 4 deletions

View File

@@ -0,0 +1,256 @@
<script lang="ts">
import { MAX_FILE_SIZE } from '@music-hub/shared';
import { api } from '$lib/api/client.js';
import Icon from '$lib/components/ui/Icon.svelte';
let { trackId, onUploaded }: { trackId: string; onUploaded: () => void } = $props();
let dragOver = $state(false);
let files = $state<{ name: string; progress: number; error: string }[]>([]);
let uploading = $state(false);
let globalError = $state('');
function stemNameFromFile(fileName: string) {
return fileName.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
dragOver = true;
}
function handleDragLeave() {
dragOver = false;
}
function handleDrop(e: DragEvent) {
e.preventDefault();
dragOver = false;
const dropped = e.dataTransfer?.files;
if (dropped && dropped.length > 0) uploadFiles(Array.from(dropped));
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) uploadFiles(Array.from(input.files));
input.value = '';
}
async function uploadFiles(selected: File[]) {
globalError = '';
const tooBig = selected.filter((f) => f.size > MAX_FILE_SIZE);
if (tooBig.length > 0) {
globalError = `${tooBig.map((f) => f.name).join(', ')} zu groß (max 500 MB)`;
return;
}
uploading = true;
files = selected.map((f) => ({ name: f.name, progress: 0, error: '' }));
// Upload in batches of 3
for (let i = 0; i < selected.length; i += 3) {
const batch = selected.slice(i, i + 3);
await Promise.all(batch.map((file, j) => uploadOne(file, i + j)));
}
uploading = false;
const anyError = files.some((f) => f.error);
if (!anyError) {
files = [];
onUploaded();
}
}
async function uploadOne(file: File, idx: number) {
try {
const { uploadUrl, fileKey } = await api.post<{ uploadUrl: string; fileKey: string }>(
`/stems/track/${trackId}/upload-url`,
{ fileName: file.name, mimeType: file.type || 'audio/wav', fileSize: file.size },
);
await uploadWithProgress(uploadUrl, file, (p) => {
files[idx] = { ...files[idx], progress: p };
});
await api.post(`/stems/track/${trackId}`, {
fileKey,
name: stemNameFromFile(file.name),
originalFileName: file.name,
mimeType: file.type || 'audio/wav',
fileSize: file.size,
});
files[idx] = { ...files[idx], progress: 100 };
} catch (err) {
files[idx] = { ...files[idx], error: err instanceof Error ? err.message : 'Fehler' };
}
}
function uploadWithProgress(url: string, file: File, onProgress: (p: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', url);
xhr.setRequestHeader('Content-Type', file.type || 'audio/wav');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`HTTP ${xhr.status}`)));
xhr.onerror = () => reject(new Error('Upload fehlgeschlagen'));
xhr.send(file);
});
}
</script>
<div class="stem-upload">
<div
class="dropzone"
class:dragover={dragOver}
class:uploading
role="button"
tabindex="0"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={() => !uploading && document.getElementById(`stem-input-${trackId}`)?.click()}
onkeydown={(e) => e.key === 'Enter' && !uploading && document.getElementById(`stem-input-${trackId}`)?.click()}
>
<input
id="stem-input-{trackId}"
type="file"
accept=".wav,.mp3,.flac,.aiff,.aif"
multiple
onchange={handleFileSelect}
hidden
/>
<div class="dropzone-content">
<span class="icon"><Icon name="upload" size={24} /></span>
<p>STEMs hier ablegen oder klicken</p>
<span class="hint">Mehrere Dateien gleichzeitig möglich · WAV, FLAC, AIFF · max 500 MB</span>
</div>
</div>
{#if files.length > 0}
<div class="file-list">
{#each files as f}
<div class="file-row" class:done={f.progress === 100} class:error={!!f.error}>
<span class="file-name">{f.name}</span>
{#if f.error}
<span class="file-error">{f.error}</span>
{:else}
<div class="file-progress">
<div class="file-bar" style="width: {f.progress}%"></div>
</div>
<span class="file-pct">{f.progress}%</span>
{/if}
</div>
{/each}
</div>
{/if}
{#if globalError}
<p class="error">{globalError}</p>
{/if}
</div>
<style>
.stem-upload {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.dropzone {
border: 2px dashed #333;
border-radius: 12px;
padding: 1.5rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: #111;
}
.dropzone:hover,
.dropzone.dragover {
border-color: #6366f1;
background: #1a1a2e;
}
.dropzone.uploading {
cursor: default;
pointer-events: none;
}
.dropzone-content p {
margin: 0.4rem 0 0.2rem;
color: #ccc;
font-size: 0.9rem;
}
.icon {
color: var(--color-text-tertiary);
display: inline-flex;
}
.hint {
font-size: 0.78rem;
color: #666;
}
.file-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.file-row {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.82rem;
}
.file-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-secondary);
}
.file-progress {
width: 80px;
height: 4px;
background: #222;
border-radius: 2px;
overflow: hidden;
flex-shrink: 0;
}
.file-bar {
height: 100%;
background: #6366f1;
transition: width 0.2s;
}
.file-row.done .file-bar {
background: #22c55e;
}
.file-pct {
width: 30px;
text-align: right;
color: var(--color-text-tertiary);
font-variant-numeric: tabular-nums;
}
.file-error {
color: #ef4444;
font-size: 0.78rem;
}
.error {
color: #ef4444;
font-size: 0.85rem;
}
</style>

View File

@@ -7,7 +7,7 @@
| 'download' | 'upload' | 'share' | 'plus' | 'check' | 'x' | 'close'
| 'chevron-down' | 'chevron-right' | 'more' | 'home' | 'panel' | 'panel-off'
| 'git-branch' | 'arrow-up' | 'compare' | 'comment' | 'lock' | 'link'
| 'settings' | 'logout' | 'list' | 'graph' | 'menu' | 'search';
| 'settings' | 'logout' | 'list' | 'graph' | 'menu' | 'search' | 'music';
let {
name,
@@ -134,6 +134,10 @@
{:else if name === 'search'}
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
{:else if name === 'music'}
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
{/if}
</svg>

View File

@@ -23,6 +23,7 @@
import VersionGraph from './components/VersionGraph.svelte';
import ShareModal from './components/ShareModal.svelte';
import CommentSection from './components/CommentSection.svelte';
import StemList from './components/StemList.svelte';
type Version = {
id: string;
@@ -85,7 +86,9 @@
let branchFromId = $state<string | null>(null);
let branchLabelInput = $state('');
let shareOpen = $state(false);
let panelTab = $state<'versions' | 'comments'>('versions');
type Stem = { id: string; name: string; originalFileName: string; mimeType: string; fileSize: number; createdAt: string; createdById: string };
let stems = $state<Stem[]>([]);
let panelTab = $state<'versions' | 'comments' | 'stems'>('versions');
let panelOpen = $state(true);
let editVersionOpen = $state(false);
let editVersionLabel = $state('');
@@ -98,11 +101,12 @@
onMount(async () => {
try {
const [projectRes, trackVersions, tracksRes, treeRes] = await Promise.all([
const [projectRes, trackVersions, tracksRes, treeRes, stemsRes] = await Promise.all([
api.get<{ project: { name: string }; role: string }>(`/projects/${projectId}`),
api.get<{ versions: Version[] }>(`/versions/track/${trackId}`),
api.get<{ tracks: { id: string; name: string; coverUrl: string | null; status: TrackStatus; section: string | null }[] }>(`/tracks/project/${projectId}`),
api.get<{ nodes: GraphNode[] }>(`/versions/track/${trackId}/tree`),
api.get<{ stems: Stem[] }>(`/stems/track/${trackId}`),
]);
projectName = projectRes.project.name;
@@ -114,6 +118,7 @@
trackSection = t?.section ?? null;
versions = trackVersions.versions;
graphNodes = treeRes.nodes;
stems = stemsRes.stems;
if (versions.length > 0) await selectVersion(versions[0]);
} finally {
@@ -502,6 +507,9 @@
<button class:active={panelTab === 'comments'} onclick={() => (panelTab = 'comments')}>
Kommentare <span class="badge">{comments.length}</span>
</button>
<button class:active={panelTab === 'stems'} onclick={() => (panelTab = 'stems')}>
STEMs <span class="badge">{stems.length}</span>
</button>
</div>
<div class="panel-body">
@@ -519,6 +527,14 @@
onBranch={canUpload ? startBranch : undefined}
/>
{/if}
{:else if panelTab === 'stems'}
<StemList
{trackId}
bind:stems
{canUpload}
currentUserId={$user?.id ?? null}
{role}
/>
{:else if selectedVersion}
<CommentSection
{comments}

View File

@@ -0,0 +1,226 @@
<script lang="ts">
import { api } from '$lib/api/client.js';
import { toastSuccess } from '$lib/stores/toast.js';
import Icon from '$lib/components/ui/Icon.svelte';
import Button from '$lib/components/ui/Button.svelte';
import StemUploadDropzone from '$lib/components/audio/StemUploadDropzone.svelte';
type Stem = {
id: string;
name: string;
originalFileName: string;
mimeType: string;
fileSize: number;
createdAt: string;
createdById: string;
};
let {
trackId,
stems = $bindable<Stem[]>([]),
canUpload,
currentUserId,
role,
}: {
trackId: string;
stems: Stem[];
canUpload: boolean;
currentUserId: string | null;
role: string;
} = $props();
let showUpload = $state(false);
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() {
const res = await api.get<{ stems: Stem[] }>(`/stems/track/${trackId}`);
stems = res.stems;
}
async function downloadZip() {
const res = await fetch(`/api/v1/stems/track/${trackId}/download-zip`, {
credentials: 'include',
});
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = res.headers.get('content-disposition')?.match(/filename="(.+?)"/)?.[1] ?? 'stems.zip';
a.click();
URL.revokeObjectURL(url);
}
async function deleteStem(id: string, name: string) {
if (!confirm(`Stem "${name}" wirklich löschen?`)) return;
deleting = id;
try {
await api.delete(`/stems/${id}`);
stems = stems.filter((s) => s.id !== id);
toastSuccess('Stem gelöscht');
} finally {
deleting = null;
}
}
</script>
<div class="stems">
<div class="stems-header">
{#if stems.length > 0}
<Button variant="ghost" size="sm" onclick={downloadZip}>
<Icon name="download" size={14} /> Alle als ZIP
</Button>
{/if}
{#if canUpload}
<Button variant="ghost" size="sm" onclick={() => (showUpload = !showUpload)}>
<Icon name="upload" size={14} /> {showUpload ? 'Schließen' : 'STEMs hochladen'}
</Button>
{/if}
</div>
{#if showUpload}
<div class="upload-box">
<StemUploadDropzone
{trackId}
onUploaded={async () => {
await loadStems();
toastSuccess('STEMs hochgeladen');
showUpload = false;
}}
/>
</div>
{/if}
{#if stems.length === 0 && !showUpload}
<p class="empty">Noch keine STEMs hochgeladen.</p>
{:else}
<ul class="stem-list">
{#each stems as stem (stem.id)}
<li class="stem-item">
<span class="stem-icon"><Icon name="music" size={14} /></span>
<div class="stem-info">
<span class="stem-name">{stem.name}</span>
<span class="stem-meta">{stem.originalFileName} · {formatSize(stem.fileSize)}</span>
</div>
{#if role === 'owner' || stem.createdById === currentUserId}
<button
class="delete-btn"
onclick={() => deleteStem(stem.id, stem.name)}
disabled={deleting === stem.id}
title="Stem löschen"
>
<Icon name="x" size={12} />
</button>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<style>
.stems {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.stems-header {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.upload-box {
background: var(--color-bg-base);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
}
.empty {
color: var(--color-text-tertiary);
font-size: var(--text-sm);
}
.stem-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.stem-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: var(--color-bg-base);
border: 1px solid var(--color-border);
transition: border-color var(--transition-fast);
}
.stem-item:hover {
border-color: var(--color-border-hover);
}
.stem-icon {
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.stem-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.stem-name {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stem-meta {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.delete-btn {
background: none;
border: none;
color: var(--color-text-tertiary);
cursor: pointer;
padding: var(--space-1);
border-radius: var(--radius-sm);
flex-shrink: 0;
display: flex;
align-items: center;
transition: color var(--transition-fast);
}
.delete-btn:hover {
color: var(--color-error);
}
.delete-btn:disabled {
opacity: 0.5;
cursor: default;
}
</style>