managarten/apps/lightwrite/apps/backend/src/beat/beat.service.ts
Till-JS 6e7b671a73 feat(lightwrite): add UX improvements and beat library
Phase 1-5 implementation:
- Keyboard shortcuts (Space, arrows, M, L, +/-)
- Dark/Light mode with theme store
- Loop regions for practice sections
- Mobile responsive view with tabs
- Beat library with preview and use functionality

Backend:
- Add library_beats schema
- Add library beat endpoints (GET list, GET download-url, POST use)

Frontend:
- BeatLibrary component with preview player
- BeatUploader tabs (Upload/Library)
- Theme-aware waveform colors
- Compact mobile playback controls

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-16 12:14:13 +01:00

182 lines
5.3 KiB
TypeScript

import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { beats, projects, markers, libraryBeats } from '../db/schema';
import type { Beat, Marker, LibraryBeat } from '../db/schema';
import {
createLightWriteStorage,
generateUserFileKey,
getContentType,
type StorageClient,
} from '@manacore/shared-storage';
@Injectable()
export class BeatService {
private storage: StorageClient;
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {
this.storage = createLightWriteStorage();
}
async findByProjectId(projectId: string): Promise<Beat | null> {
const [beat] = await this.db.select().from(beats).where(eq(beats.projectId, projectId));
return beat || null;
}
async findById(id: string): Promise<Beat | null> {
const [beat] = await this.db.select().from(beats).where(eq(beats.id, id));
return beat || null;
}
async findByIdOrThrow(id: string): Promise<Beat> {
const beat = await this.findById(id);
if (!beat) {
throw new NotFoundException('Beat not found');
}
return beat;
}
async verifyProjectOwnership(projectId: string, userId: string): Promise<void> {
const [project] = await this.db
.select()
.from(projects)
.where(and(eq(projects.id, projectId), eq(projects.userId, userId)));
if (!project) {
throw new NotFoundException('Project not found');
}
}
async createUploadUrl(
projectId: string,
userId: string,
filename: string
): Promise<{ beat: Beat; uploadUrl: string }> {
await this.verifyProjectOwnership(projectId, userId);
// Check if beat already exists for this project
const existingBeat = await this.findByProjectId(projectId);
if (existingBeat) {
throw new BadRequestException('Beat already exists for this project. Delete it first.');
}
const key = generateUserFileKey(userId, filename);
const contentType = getContentType(filename);
if (!contentType.startsWith('audio/') && !['application/octet-stream'].includes(contentType)) {
throw new BadRequestException('Invalid file type. Only audio files are allowed.');
}
// Create beat record
const [beat] = await this.db
.insert(beats)
.values({
projectId,
storagePath: key,
filename,
})
.returning();
// Generate presigned upload URL
const uploadUrl = await this.storage.getUploadUrl(key, {
expiresIn: 3600,
});
return { beat, uploadUrl };
}
async updateBeatMetadata(
id: string,
userId: string,
data: {
duration?: number;
bpm?: number;
bpmConfidence?: number;
waveformData?: unknown;
}
): Promise<Beat> {
const beat = await this.findByIdOrThrow(id);
await this.verifyProjectOwnership(beat.projectId, userId);
const [updatedBeat] = await this.db.update(beats).set(data).where(eq(beats.id, id)).returning();
return updatedBeat;
}
async getDownloadUrl(id: string, userId: string): Promise<string> {
const beat = await this.findByIdOrThrow(id);
await this.verifyProjectOwnership(beat.projectId, userId);
return this.storage.getDownloadUrl(beat.storagePath, { expiresIn: 3600 });
}
async delete(id: string, userId: string): Promise<void> {
const beat = await this.findByIdOrThrow(id);
await this.verifyProjectOwnership(beat.projectId, userId);
// Delete from storage
try {
await this.storage.delete(beat.storagePath);
} catch {
// Ignore storage errors, continue with DB deletion
}
// Delete from database (markers will be cascade deleted)
await this.db.delete(beats).where(eq(beats.id, id));
}
async getMarkersForBeat(beatId: string): Promise<Marker[]> {
return this.db.select().from(markers).where(eq(markers.beatId, beatId));
}
// ==================== Library Beats ====================
async getLibraryBeats(): Promise<LibraryBeat[]> {
return this.db
.select()
.from(libraryBeats)
.where(eq(libraryBeats.isActive, true))
.orderBy(libraryBeats.title);
}
async getLibraryBeatById(id: string): Promise<LibraryBeat | null> {
const [beat] = await this.db.select().from(libraryBeats).where(eq(libraryBeats.id, id));
return beat || null;
}
async getLibraryBeatDownloadUrl(id: string): Promise<string> {
const beat = await this.getLibraryBeatById(id);
if (!beat) {
throw new NotFoundException('Library beat not found');
}
return this.storage.getDownloadUrl(beat.storagePath, { expiresIn: 3600 });
}
async useLibraryBeat(libraryBeatId: string, projectId: string, userId: string): Promise<Beat> {
await this.verifyProjectOwnership(projectId, userId);
// Check if beat already exists for this project
const existingBeat = await this.findByProjectId(projectId);
if (existingBeat) {
throw new BadRequestException('Beat already exists for this project. Delete it first.');
}
const libraryBeat = await this.getLibraryBeatById(libraryBeatId);
if (!libraryBeat) {
throw new NotFoundException('Library beat not found');
}
// Create beat record referencing the same storage path
const [beat] = await this.db
.insert(beats)
.values({
projectId,
storagePath: libraryBeat.storagePath,
filename: `${libraryBeat.title}${libraryBeat.artist ? ` - ${libraryBeat.artist}` : ''}.mp3`,
duration: libraryBeat.duration,
bpm: libraryBeat.bpm,
})
.returning();
return beat;
}
}