feat(mukke): rename LightWrite to Mukke and add music library, player, playlists

Combines LightWrite (beat/lyrics editor) and Mukke (iOS music player) into
a single web-based music workspace app. Archives the old Mukke mobile app.

- Rename: @lightwrite/* → @mukke/*, all branding, configs, Dockerfiles
- New DB schemas: songs, playlists, playlist_songs + songId FK on projects
- New backend modules: SongModule, PlaylistModule, LibraryModule
- New web: app shell with sidebar, library (songs/albums/artists/genres),
  web player (queue/shuffle/repeat/MediaSession), playlists, search,
  upload, dashboard, album/artist/genre detail pages
- Auth: add forgot-password + reset-password pages, extend auth store
- Tests: 40 backend unit tests (song, playlist, library services)
- Config: env generation, MinIO bucket, docker-compose prod, cloudflare
- Docs: update CLAUDE.md, auth guidelines with SvelteKit checklist

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-19 09:55:56 +01:00
parent ea4b585f37
commit 7a56699d45
199 changed files with 12406 additions and 75 deletions

View file

@ -782,6 +782,102 @@ await request(app.getHttpServer())
.expect(200);
```
## SvelteKit Auth Routes Checklist
When creating a new SvelteKit web app, **ALL** of the following auth routes MUST be implemented:
### Required Routes (in `src/routes/(auth)/`)
| Route | Page | Component | Store Method |
|-------|------|-----------|-------------|
| `/login` | `login/+page.svelte` | `LoginPage` from `@manacore/shared-auth-ui` | `authStore.signIn()` |
| `/register` | `register/+page.svelte` | `RegisterPage` from `@manacore/shared-auth-ui` | `authStore.signUp()` |
| `/forgot-password` | `forgot-password/+page.svelte` | `ForgotPasswordPage` from `@manacore/shared-auth-ui` | `authStore.resetPassword()` |
| `/reset-password` | `reset-password/+page.svelte` | Custom form (token from URL) | `authStore.resetPasswordWithToken()` |
### Required Auth Store Methods
The `auth.svelte.ts` store MUST implement these methods using `@manacore/shared-auth`:
| Method | Shared Auth Method | Purpose |
|--------|-------------------|---------|
| `signIn(email, password)` | `authService.signIn()` | Login |
| `signUp(email, password)` | `authService.signUp()` | Register |
| `signOut()` | `authService.signOut()` | Logout |
| `resetPassword(email)` | `authService.forgotPassword()` | Send reset email |
| `resetPasswordWithToken(token, pw)` | `authService.resetPassword()` | Reset with token |
| `resendVerificationEmail(email)` | `authService.resendVerificationEmail()` | Resend verification |
| `getValidToken()` | `tokenManager.getValidToken()` | Get valid JWT |
| `getAuthHeaders()` | Direct localStorage read | Get `Authorization` header |
### Login Page Template
```svelte
<script lang="ts">
import { goto } from '$app/navigation';
import { LoginPage } from '@manacore/shared-auth-ui';
import { getLoginTranslations } from '@manacore/shared-i18n';
import { YourAppLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
const translations = getLoginTranslations('en');
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
</script>
<LoginPage
appName="YourApp"
logo={YourAppLogo}
primaryColor="#your-color"
onSignIn={handleSignIn}
{goto}
forgotPasswordPath="/forgot-password"
registerPath="/register"
{translations}
/>
```
### Forgot Password Page Template
```svelte
<script lang="ts">
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { getForgotPasswordTranslations } from '@manacore/shared-i18n';
import { YourAppLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
const translations = getForgotPasswordTranslations('en');
async function handleForgotPassword(email: string) {
return authStore.resetPassword(email);
}
</script>
<ForgotPasswordPage
appName="YourApp"
logo={YourAppLogo}
primaryColor="#your-color"
onForgotPassword={handleForgotPassword}
{goto}
loginPath="/login"
{translations}
/>
```
### Reset Password Page
The reset password page is a **custom implementation** (not a shared component) because it handles token validation from URL params. See `apps/calendar/apps/web/src/routes/(auth)/reset-password/+page.svelte` as reference.
Key requirements:
- Read `token` from `$page.url.searchParams`
- Validate password length (min 8 chars) and match
- Call `authStore.resetPasswordWithToken(token, password)`
- Show success state and redirect to `/login` after 3 seconds
- Show invalid token state with link to `/forgot-password`
## Security Considerations
1. **Store tokens securely**

View file

@ -0,0 +1,60 @@
# CLAUDE.md - Mukke
Offline-first iOS Music Player. Songs aus iCloud/lokalen Dateien importieren, lokal auf dem Gerät speichern, abspielen.
## Project Structure
```
apps/mukke/
├── package.json # Orchestrator (name: mukke)
├── apps/
│ └── mobile/ # @mukke/mobile (Expo SDK 54)
│ ├── app/ # Expo Router screens
│ │ ├── (tabs)/ # 4 Tab-Screens (Bibliothek, Playlists, Suche, Settings)
│ │ ├── player.tsx # Full-Screen Player (modal)
│ │ ├── queue.tsx # Queue Ansicht (modal)
│ │ ├── album/[id] # Album Detail
│ │ ├── artist/[id] # Artist Detail
│ │ └── playlist/ # Playlist Detail + New
│ ├── components/ # UI components
│ ├── contexts/ # AudioContext (expo-audio)
│ ├── stores/ # Zustand stores (player, library, playlist)
│ ├── services/ # Business logic (DB, import, audio, library, playlist)
│ └── utils/ # Theme system
└── packages/
└── mukke-types/ # @mukke/types (shared interfaces)
```
## Commands
```bash
pnpm dev:mukke:mobile # Start Expo app
```
## Tech Stack
- **Audio**: expo-audio (background via UIBackgroundModes: ["audio"])
- **Import**: expo-document-picker (iCloud + lokale Dateien)
- **Storage**: expo-file-system (documentDirectory)
- **Metadata**: @missingcore/audio-metadata (ID3v2.3/v2.4)
- **DB**: expo-sqlite (SQLite für Songs, Playlists)
- **State**: Zustand
- **Navigation**: Expo Router + NativeTabs
- **Styling**: NativeWind / Tailwind
## Architecture
- **No backend** - pure offline, local-only app
- **SQLite** for structured data (songs, playlists, playlist_songs)
- **Albums/Artists/Genres** derived from songs table via queries (no separate tables)
- **File storage**: documentDirectory/music/ + documentDirectory/artwork/
- **Audio playback**: expo-audio with background mode
- **MiniPlayer**: persistent above tab bar
## Import Flow
1. User taps Import → expo-document-picker opens (iCloud + local)
2. Files copied to documentDirectory/music/{uuid}.ext
3. Metadata extracted via @missingcore/audio-metadata
4. Cover art saved to documentDirectory/artwork/{uuid}.jpg
5. Song entry created in SQLite

View file

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 115 B

After

Width:  |  Height:  |  Size: 115 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -0,0 +1,9 @@
{
"name": "mukke",
"version": "1.0.0",
"private": true,
"description": "Mukke - Offline-first iOS Music Player",
"scripts": {
"dev": "pnpm run --filter=@mukke/* --parallel dev"
}
}

View file

@ -1,60 +1,166 @@
# CLAUDE.md - Mukke
# Mukke - Music Workspace
Offline-first iOS Music Player. Songs aus iCloud/lokalen Dateien importieren, lokal auf dem Gerät speichern, abspielen.
## Project Structure
```
apps/mukke/
├── package.json # Orchestrator (name: mukke)
├── apps/
│ └── mobile/ # @mukke/mobile (Expo SDK 54)
│ ├── app/ # Expo Router screens
│ │ ├── (tabs)/ # 4 Tab-Screens (Bibliothek, Playlists, Suche, Settings)
│ │ ├── player.tsx # Full-Screen Player (modal)
│ │ ├── queue.tsx # Queue Ansicht (modal)
│ │ ├── album/[id] # Album Detail
│ │ ├── artist/[id] # Artist Detail
│ │ └── playlist/ # Playlist Detail + New
│ ├── components/ # UI components
│ ├── contexts/ # AudioContext (expo-audio)
│ ├── stores/ # Zustand stores (player, library, playlist)
│ ├── services/ # Business logic (DB, import, audio, library, playlist)
│ └── utils/ # Theme system
└── packages/
└── mukke-types/ # @mukke/types (shared interfaces)
```
## Commands
```bash
pnpm dev:mukke:mobile # Start Expo app
```
## Tech Stack
- **Audio**: expo-audio (background via UIBackgroundModes: ["audio"])
- **Import**: expo-document-picker (iCloud + lokale Dateien)
- **Storage**: expo-file-system (documentDirectory)
- **Metadata**: @missingcore/audio-metadata (ID3v2.3/v2.4)
- **DB**: expo-sqlite (SQLite für Songs, Playlists)
- **State**: Zustand
- **Navigation**: Expo Router + NativeTabs
- **Styling**: NativeWind / Tailwind
Mukke is a web application for managing your music library, playing tracks, and creating synchronized lyrics. It combines a music player with a beat/lyrics editor featuring waveform visualization, BPM detection, timestamp markers, and exports to multiple formats.
## Architecture
- **No backend** - pure offline, local-only app
- **SQLite** for structured data (songs, playlists, playlist_songs)
- **Albums/Artists/Genres** derived from songs table via queries (no separate tables)
- **File storage**: documentDirectory/music/ + documentDirectory/artwork/
- **Audio playback**: expo-audio with background mode
- **MiniPlayer**: persistent above tab bar
```
apps/mukke/
├── apps/
│ ├── backend/ # NestJS API (port 3010)
│ ├── web/ # SvelteKit app (port 5180)
│ └── landing/ # Astro marketing page
├── packages/
│ └── shared/ # Shared types (@mukke/shared)
└── package.json
```
## Import Flow
## Quick Start
1. User taps Import → expo-document-picker opens (iCloud + local)
2. Files copied to documentDirectory/music/{uuid}.ext
3. Metadata extracted via @missingcore/audio-metadata
4. Cover art saved to documentDirectory/artwork/{uuid}.jpg
5. Song entry created in SQLite
```bash
# Start with full database setup
pnpm dev:mukke:full
# Or start components individually
pnpm docker:up # Start PostgreSQL, Redis, MinIO
pnpm --filter @mukke/backend dev # Backend on port 3010
pnpm --filter @mukke/web dev # Web on port 5180
pnpm --filter @mukke/landing dev # Landing page
```
## Backend API Endpoints
### Songs (Library)
- `POST /songs/upload` - Upload song and get presigned URL
- `GET /songs` - List user's songs (with sort/filter)
- `GET /songs/:id` - Get song details
- `PUT /songs/:id` - Update song metadata
- `PUT /songs/:id/favorite` - Toggle favorite
- `PUT /songs/:id/play` - Increment play count
- `DELETE /songs/:id` - Delete song
- `GET /songs/search?q=` - Search songs
### Playlists
- `GET /playlists` - List user's playlists
- `POST /playlists` - Create playlist
- `GET /playlists/:id` - Get playlist with songs
- `PUT /playlists/:id` - Update playlist
- `DELETE /playlists/:id` - Delete playlist
- `POST /playlists/:id/songs` - Add song to playlist
- `DELETE /playlists/:id/songs/:songId` - Remove song
- `PUT /playlists/:id/songs/reorder` - Reorder songs
### Library (Aggregates)
- `GET /library/albums` - Get albums (grouped)
- `GET /library/artists` - Get artists (grouped)
- `GET /library/genres` - Get genres (grouped)
- `GET /library/stats` - Library statistics
### Projects (Editor)
- `GET /projects` - List user's projects
- `GET /projects/:id` - Get project with beat and lyrics
- `POST /projects` - Create project
- `POST /projects/from-song/:songId` - Create project from library song
- `PUT /projects/:id` - Update project
- `DELETE /projects/:id` - Delete project
### Beats
- `GET /beats/project/:projectId` - Get beat for project
- `GET /beats/:id` - Get beat with markers
- `GET /beats/:id/download-url` - Get presigned download URL
- `POST /beats/upload` - Create beat and get upload URL
- `PUT /beats/:id/metadata` - Update BPM, duration, waveform data
- `DELETE /beats/:id` - Delete beat
### Markers
- `GET /markers/beat/:beatId` - Get markers for beat
- `POST /markers` - Create marker
- `POST /markers/bulk` - Bulk create markers
- `PUT /markers/:id` - Update marker
- `PUT /markers/bulk` - Bulk update markers
- `DELETE /markers/:id` - Delete marker
### Lyrics
- `GET /lyrics/project/:projectId` - Get lyrics with synced lines
- `POST /lyrics/project/:projectId` - Create/update lyrics content
- `POST /lyrics/:id/sync` - Sync line timestamps
### Export
- `GET /export/:projectId?format=lrc|srt|json` - Export project
## Database Schema
```typescript
// songs - Music library
{ id, userId, title, artist, album, albumArtist, genre, trackNumber, year, duration,
storagePath, coverArtPath, fileSize, bpm, favorite, playCount, lastPlayedAt, addedAt, updatedAt }
// playlists - User playlists
{ id, userId, name, description, coverArtPath, createdAt, updatedAt }
// playlist_songs - Playlist-Song join table
{ id, playlistId, songId, sortOrder, addedAt }
// projects - Editor projects
{ id, userId, title, description, songId, createdAt, updatedAt }
// beats - Audio files for editor
{ id, projectId, storagePath, filename, duration, bpm, bpmConfidence, waveformData }
// markers - Section markers
{ id, beatId, type, label, startTime, endTime, color, sortOrder }
// lyrics - Full lyrics text
{ id, projectId, content }
// lyric_lines - Synced lines
{ id, lyricsId, lineNumber, text, startTime, endTime }
```
## Key Technologies
| Component | Technology |
|-----------|------------|
| Frontend | SvelteKit 2, Svelte 5, Tailwind CSS 4 |
| Waveform | wavesurfer.js 7.x |
| BPM Detection | Web Audio API (peak detection) |
| Metadata | music-metadata (server-side) |
| Backend | NestJS 10, Drizzle ORM |
| Database | PostgreSQL |
| Storage | MinIO (dev) / Hetzner S3 (prod) |
| Auth | mana-core-auth |
## Environment Variables
### Backend (.env)
```
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/mukke
MANA_CORE_AUTH_URL=http://localhost:3001
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=mukke-storage
```
### Web (.env)
```
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
PUBLIC_BACKEND_URL=http://localhost:3010
```
## Development Commands
```bash
# Database
pnpm --filter @mukke/backend db:push # Push schema
pnpm --filter @mukke/backend db:studio # Open Drizzle Studio
# Type checking
pnpm --filter @mukke/backend type-check
pnpm --filter @mukke/web type-check
# Build
pnpm --filter @mukke/backend build
pnpm --filter @mukke/web build
```

View file

@ -0,0 +1,19 @@
# Database
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/mukke
# Auth
MANA_CORE_AUTH_URL=http://localhost:3001
NODE_ENV=development
DEV_BYPASS_AUTH=true
DEV_USER_ID=dev-user-id
# Storage (S3/MinIO)
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=mukke-storage
# STT (Speech-to-Text)
MANA_STT_URL=http://localhost:3020
# MANA_STT_API_KEY= # Optional, only if mana-stt requires auth

View file

@ -0,0 +1,82 @@
# Build stage
FROM node:20-alpine AS builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
# Copy shared packages
COPY packages/shared-drizzle-config ./packages/shared-drizzle-config
COPY packages/shared-errors ./packages/shared-errors
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
COPY packages/shared-nestjs-health ./packages/shared-nestjs-health
COPY packages/shared-nestjs-setup ./packages/shared-nestjs-setup
COPY packages/shared-storage ./packages/shared-storage
COPY packages/shared-tsconfig ./packages/shared-tsconfig
# Copy mukke packages
COPY apps/mukke/packages ./apps/mukke/packages
COPY apps/mukke/apps/backend ./apps/mukke/apps/backend
# Install dependencies
RUN pnpm install --frozen-lockfile --ignore-scripts
# Build shared packages
WORKDIR /app/packages/shared-errors
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-auth
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-health
RUN pnpm build
WORKDIR /app/packages/shared-storage
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-setup
RUN pnpm build
# Build the backend
WORKDIR /app/apps/mukke/apps/backend
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Install pnpm and postgresql-client
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
&& apk add --no-cache postgresql-client
WORKDIR /app
# Copy everything from builder
COPY --from=builder /app/pnpm-workspace.yaml ./
COPY --from=builder /app/package.json ./
COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages ./packages
COPY --from=builder /app/apps/mukke ./apps/mukke
# Copy entrypoint script
COPY apps/mukke/apps/backend/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
WORKDIR /app/apps/mukke/apps/backend
# Expose port
EXPOSE 3010
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3010/health || exit 1
# Run entrypoint script
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "dist/main.js"]

View file

@ -0,0 +1,46 @@
#!/bin/sh
set -e
echo "=========================================="
echo " Mukke Backend Startup"
echo "=========================================="
echo "Environment: ${NODE_ENV:-development}"
echo "Port: ${PORT:-3010}"
# Wait for database to be ready
if [ -n "$DATABASE_URL" ]; then
echo "Waiting for database..."
# Extract host and port from DATABASE_URL
DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p')
DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p')
# Default port if not found
DB_PORT=${DB_PORT:-5432}
# Wait for database to accept connections
max_attempts=30
attempt=1
while [ $attempt -le $max_attempts ]; do
if pg_isready -h "$DB_HOST" -p "$DB_PORT" > /dev/null 2>&1; then
echo "Database is ready!"
break
fi
echo "Waiting for database... (attempt $attempt/$max_attempts)"
sleep 2
attempt=$((attempt + 1))
done
if [ $attempt -gt $max_attempts ]; then
echo "Warning: Could not connect to database after $max_attempts attempts"
fi
fi
# Push database schema (safe for production - only adds missing tables/columns)
if [ "$RUN_DB_PUSH" = "true" ]; then
echo "Pushing database schema..."
npx drizzle-kit push --force || echo "Warning: db:push failed, continuing anyway..."
fi
echo "Starting application..."
exec "$@"

View file

@ -0,0 +1,6 @@
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({
dbName: 'mukke',
additionalEnvVars: ['MUKKE_DATABASE_URL'],
});

View file

@ -0,0 +1,24 @@
/** @type {import('jest').Config} */
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['**/*.(t|j)s', '!**/*.spec.ts', '!**/index.ts', '!main.ts'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
moduleNameMapper: {
'^@mukke/shared$': '<rootDir>/../../packages/shared/src',
},
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
transformIgnorePatterns: ['node_modules/(?!(@mukke|@manacore)/)'],
};

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -0,0 +1,66 @@
{
"name": "@mukke/backend",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"migration:generate": "drizzle-kit generate",
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage"
},
"dependencies": {
"@manacore/shared-drizzle-config": "workspace:*",
"@manacore/shared-nestjs-auth": "workspace:*",
"@manacore/shared-nestjs-health": "workspace:*",
"@manacore/shared-nestjs-setup": "workspace:*",
"@manacore/shared-storage": "workspace:*",
"@mukke/shared": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"music-metadata": "^11.12.3",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.20",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.2",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"jest": "^30.3.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-jest": "^29.4.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,54 @@
import type { Song } from '../../db/schema/songs.schema';
import type { Playlist, PlaylistSong } from '../../db/schema/playlists.schema';
export const TEST_USER_ID = 'test-user-123';
export const TEST_USER_EMAIL = 'test@example.com';
export function createMockSong(overrides?: Partial<Song>): Song {
return {
id: crypto.randomUUID(),
userId: TEST_USER_ID,
title: 'Test Song',
artist: 'Test Artist',
album: 'Test Album',
albumArtist: null,
genre: 'Rock',
trackNumber: 1,
year: 2024,
duration: 240.5,
storagePath: 'users/test-user-123/audio.mp3',
coverArtPath: null,
fileSize: 5000000,
bpm: 120.0,
favorite: false,
playCount: 0,
lastPlayedAt: null,
addedAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
export function createMockPlaylist(overrides?: Partial<Playlist>): Playlist {
return {
id: crypto.randomUUID(),
userId: TEST_USER_ID,
name: 'Test Playlist',
description: 'A test playlist',
coverArtPath: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
export function createMockPlaylistSong(overrides?: Partial<PlaylistSong>): PlaylistSong {
return {
id: crypto.randomUUID(),
playlistId: crypto.randomUUID(),
songId: crypto.randomUUID(),
sortOrder: 0,
addedAt: new Date(),
...overrides,
};
}

View file

@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { ProjectModule } from './project/project.module';
import { BeatModule } from './beat/beat.module';
import { MarkerModule } from './marker/marker.module';
import { LyricsModule } from './lyrics/lyrics.module';
import { ExportModule } from './export/export.module';
import { SttModule } from './stt/stt.module';
import { SongModule } from './song/song.module';
import { PlaylistModule } from './playlist/playlist.module';
import { LibraryModule } from './library/library.module';
import { HealthModule } from '@manacore/shared-nestjs-health';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
ProjectModule,
BeatModule,
MarkerModule,
LyricsModule,
ExportModule,
SttModule,
SongModule,
PlaylistModule,
LibraryModule,
HealthModule.forRoot({ serviceName: 'mukke-backend' }),
],
})
export class AppModule {}

View file

@ -0,0 +1,128 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { BeatService } from './beat.service';
import { CreateBeatUploadDto, UpdateBeatMetadataDto, UseLibraryBeatDto } from './dto/beat.dto';
@Controller('beats')
export class BeatController {
constructor(private readonly beatService: BeatService) {}
// ==================== Library Beats (Public) ====================
@Get('library')
async getLibraryBeats() {
const beats = await this.beatService.getLibraryBeats();
return { beats };
}
@Get('library/:id')
async getLibraryBeat(@Param('id', ParseUUIDPipe) id: string) {
const beat = await this.beatService.getLibraryBeatById(id);
if (!beat) {
return { beat: null };
}
return { beat };
}
@Get('library/:id/download-url')
async getLibraryBeatDownloadUrl(@Param('id', ParseUUIDPipe) id: string) {
const url = await this.beatService.getLibraryBeatDownloadUrl(id);
return { url };
}
@Post('library/:id/use')
@UseGuards(JwtAuthGuard)
async useLibraryBeat(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UseLibraryBeatDto
) {
const beat = await this.beatService.useLibraryBeat(id, dto.projectId, user.userId);
return { beat };
}
// ==================== STT Transcription ====================
@Get('stt/available')
async getSttAvailability() {
const available = await this.beatService.isSttAvailable();
return { available };
}
// ==================== User Beats (Protected) ====================
@Get('project/:projectId')
@UseGuards(JwtAuthGuard)
async findByProject(
@CurrentUser() user: CurrentUserData,
@Param('projectId', ParseUUIDPipe) projectId: string
) {
await this.beatService.verifyProjectOwnership(projectId, user.userId);
const beat = await this.beatService.findByProjectId(projectId);
return { beat };
}
@Get(':id')
@UseGuards(JwtAuthGuard)
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const beat = await this.beatService.findByIdOrThrow(id);
await this.beatService.verifyProjectOwnership(beat.projectId, user.userId);
const beatMarkers = await this.beatService.getMarkersForBeat(id);
return { beat, markers: beatMarkers };
}
@Get(':id/download-url')
@UseGuards(JwtAuthGuard)
async getDownloadUrl(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
const url = await this.beatService.getDownloadUrl(id, user.userId);
return { url };
}
@Post('upload')
@UseGuards(JwtAuthGuard)
async createUploadUrl(@CurrentUser() user: CurrentUserData, @Body() dto: CreateBeatUploadDto) {
const result = await this.beatService.createUploadUrl(dto.projectId, user.userId, dto.filename);
return result;
}
@Put(':id/metadata')
@UseGuards(JwtAuthGuard)
async updateMetadata(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateBeatMetadataDto
) {
const beat = await this.beatService.updateBeatMetadata(id, user.userId, dto);
return { beat };
}
@Delete(':id')
@UseGuards(JwtAuthGuard)
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
await this.beatService.delete(id, user.userId);
return { success: true };
}
@Post(':id/transcribe')
@UseGuards(JwtAuthGuard)
async transcribeBeat(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
const result = await this.beatService.transcribeBeat(id, user.userId);
return result;
}
}

View file

@ -0,0 +1,13 @@
import { Module, forwardRef } from '@nestjs/common';
import { BeatController } from './beat.controller';
import { BeatService } from './beat.service';
import { SttModule } from '../stt/stt.module';
import { LyricsModule } from '../lyrics/lyrics.module';
@Module({
imports: [SttModule, forwardRef(() => LyricsModule)],
controllers: [BeatController],
providers: [BeatService],
exports: [BeatService],
})
export class BeatModule {}

View file

@ -0,0 +1,267 @@
import { Injectable, Inject, NotFoundException, BadRequestException, Logger } 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 {
createMukkeStorage,
generateUserFileKey,
getContentType,
type StorageClient,
} from '@manacore/shared-storage';
import { SttService } from '../stt/stt.service';
import { LyricsService } from '../lyrics/lyrics.service';
@Injectable()
export class BeatService {
private readonly logger = new Logger(BeatService.name);
private storage: StorageClient;
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private sttService: SttService,
private lyricsService: LyricsService
) {
this.storage = createMukkeStorage();
}
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;
}
// ==================== STT Transcription ====================
/**
* Check if STT service is available
*/
async isSttAvailable(): Promise<boolean> {
return this.sttService.isAvailable();
}
/**
* Transcribe beat audio and save lyrics to the project
*/
async transcribeBeat(
beatId: string,
userId: string
): Promise<{ beat: Beat; lyrics: string | null }> {
const beat = await this.findByIdOrThrow(beatId);
await this.verifyProjectOwnership(beat.projectId, userId);
// Set status to pending
await this.db
.update(beats)
.set({
transcriptionStatus: 'pending',
transcriptionError: null,
})
.where(eq(beats.id, beatId));
try {
this.logger.log(`Starting transcription for beat ${beatId}`);
// Download audio from storage
const audioBuffer = await this.storage.download(beat.storagePath);
// Call STT service
const result = await this.sttService.transcribe(audioBuffer, beat.filename || 'audio.mp3');
// Save transcribed text as lyrics
const lyricsRecord = await this.lyricsService.createOrUpdate(
beat.projectId,
userId,
result.text
);
// Update beat status to completed
const [updatedBeat] = await this.db
.update(beats)
.set({
transcriptionStatus: 'completed',
transcribedAt: new Date(),
transcriptionError: null,
})
.where(eq(beats.id, beatId))
.returning();
this.logger.log(`Transcription completed for beat ${beatId}: ${result.text.length} chars`);
return {
beat: updatedBeat,
lyrics: lyricsRecord.content,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Transcription failed for beat ${beatId}: ${errorMessage}`);
// Update beat status to failed
await this.db
.update(beats)
.set({
transcriptionStatus: 'failed',
transcriptionError: errorMessage,
})
.where(eq(beats.id, beatId));
throw error;
}
}
}

View file

@ -0,0 +1,39 @@
import { IsString, IsNotEmpty, IsUUID, IsNumber, IsOptional, IsObject } from 'class-validator';
export class CreateBeatUploadDto {
@IsUUID()
@IsNotEmpty()
projectId!: string;
@IsString()
@IsNotEmpty()
filename!: string;
}
export class UseLibraryBeatDto {
@IsUUID()
@IsNotEmpty()
projectId!: string;
}
export class UpdateBeatMetadataDto {
@IsNumber()
@IsOptional()
duration?: number;
@IsNumber()
@IsOptional()
bpm?: number;
@IsNumber()
@IsOptional()
bpmConfidence?: number;
@IsObject()
@IsOptional()
waveformData?: {
peaks: number[];
sampleRate: number;
duration: number;
};
}

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,29 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection } from './connection';
import type { Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -0,0 +1,26 @@
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
async function main() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
const connection = postgres(databaseUrl, { max: 1 });
const db = drizzle(connection);
console.log('Running migrations...');
await migrate(db, { migrationsFolder: './drizzle' });
console.log('Migrations complete!');
await connection.end();
process.exit(0);
}
main().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});

View file

@ -0,0 +1,23 @@
import { pgTable, uuid, text, timestamp, varchar, real, jsonb } from 'drizzle-orm/pg-core';
import { projects } from './projects.schema';
export const beats = pgTable('beats', {
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id')
.references(() => projects.id, { onDelete: 'cascade' })
.notNull(),
storagePath: text('storage_path').notNull(),
filename: varchar('filename', { length: 255 }),
duration: real('duration'),
bpm: real('bpm'),
bpmConfidence: real('bpm_confidence'),
waveformData: jsonb('waveform_data'),
// STT Transcription fields
transcriptionStatus: varchar('transcription_status', { length: 50 }).default('none'), // 'none' | 'pending' | 'completed' | 'failed'
transcriptionError: text('transcription_error'),
transcribedAt: timestamp('transcribed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Beat = typeof beats.$inferSelect;
export type NewBeat = typeof beats.$inferInsert;

View file

@ -0,0 +1,7 @@
export * from './projects.schema';
export * from './beats.schema';
export * from './markers.schema';
export * from './lyrics.schema';
export * from './library-beats.schema';
export * from './songs.schema';
export * from './playlists.schema';

View file

@ -0,0 +1,24 @@
import { pgTable, uuid, text, timestamp, varchar, real, boolean } from 'drizzle-orm/pg-core';
/**
* Library beats are free beats available to all users.
* They are pre-uploaded by admins and can be used in any project.
*/
export const libraryBeats = pgTable('library_beats', {
id: uuid('id').primaryKey().defaultRandom(),
title: varchar('title', { length: 255 }).notNull(),
artist: varchar('artist', { length: 255 }),
genre: varchar('genre', { length: 100 }),
bpm: real('bpm'),
duration: real('duration'),
storagePath: text('storage_path').notNull(),
previewUrl: text('preview_url'),
license: varchar('license', { length: 100 }).default('free'),
isActive: boolean('is_active').default(true),
tags: text('tags').array(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type LibraryBeat = typeof libraryBeats.$inferSelect;
export type NewLibraryBeat = typeof libraryBeats.$inferInsert;

View file

@ -0,0 +1,27 @@
import { pgTable, uuid, text, real, integer } from 'drizzle-orm/pg-core';
import { projects } from './projects.schema';
export const lyrics = pgTable('lyrics', {
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id')
.references(() => projects.id, { onDelete: 'cascade' })
.notNull()
.unique(),
content: text('content'),
});
export const lyricLines = pgTable('lyric_lines', {
id: uuid('id').primaryKey().defaultRandom(),
lyricsId: uuid('lyrics_id')
.references(() => lyrics.id, { onDelete: 'cascade' })
.notNull(),
lineNumber: integer('line_number').notNull(),
text: text('text').notNull(),
startTime: real('start_time'),
endTime: real('end_time'),
});
export type Lyrics = typeof lyrics.$inferSelect;
export type NewLyrics = typeof lyrics.$inferInsert;
export type LyricLine = typeof lyricLines.$inferSelect;
export type NewLyricLine = typeof lyricLines.$inferInsert;

View file

@ -0,0 +1,18 @@
import { pgTable, uuid, varchar, real, integer } from 'drizzle-orm/pg-core';
import { beats } from './beats.schema';
export const markers = pgTable('markers', {
id: uuid('id').primaryKey().defaultRandom(),
beatId: uuid('beat_id')
.references(() => beats.id, { onDelete: 'cascade' })
.notNull(),
type: varchar('type', { length: 50 }).notNull(),
label: varchar('label', { length: 100 }),
startTime: real('start_time').notNull(),
endTime: real('end_time'),
color: varchar('color', { length: 7 }),
sortOrder: integer('sort_order'),
});
export type Marker = typeof markers.$inferSelect;
export type NewMarker = typeof markers.$inferInsert;

View file

@ -0,0 +1,29 @@
import { pgTable, uuid, text, timestamp, varchar, integer } from 'drizzle-orm/pg-core';
import { songs } from './songs.schema';
export const playlists = pgTable('playlists', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
coverArtPath: text('cover_art_path'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const playlistSongs = pgTable('playlist_songs', {
id: uuid('id').primaryKey().defaultRandom(),
playlistId: uuid('playlist_id')
.references(() => playlists.id, { onDelete: 'cascade' })
.notNull(),
songId: uuid('song_id')
.references(() => songs.id, { onDelete: 'cascade' })
.notNull(),
sortOrder: integer('sort_order').notNull(),
addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Playlist = typeof playlists.$inferSelect;
export type NewPlaylist = typeof playlists.$inferInsert;
export type PlaylistSong = typeof playlistSongs.$inferSelect;
export type NewPlaylistSong = typeof playlistSongs.$inferInsert;

View file

@ -0,0 +1,15 @@
import { pgTable, uuid, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { songs } from './songs.schema';
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
title: varchar('title', { length: 255 }).notNull(),
description: text('description'),
songId: uuid('song_id').references(() => songs.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;

View file

@ -0,0 +1,35 @@
import {
pgTable,
uuid,
text,
timestamp,
varchar,
integer,
real,
boolean,
} from 'drizzle-orm/pg-core';
export const songs = pgTable('songs', {
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
title: varchar('title', { length: 255 }).notNull(),
artist: varchar('artist', { length: 255 }),
album: varchar('album', { length: 255 }),
albumArtist: varchar('album_artist', { length: 255 }),
genre: varchar('genre', { length: 100 }),
trackNumber: integer('track_number'),
year: integer('year'),
duration: real('duration'),
storagePath: text('storage_path').notNull(),
coverArtPath: text('cover_art_path'),
fileSize: integer('file_size'),
bpm: real('bpm'),
favorite: boolean('favorite').default(false).notNull(),
playCount: integer('play_count').default(0).notNull(),
lastPlayedAt: timestamp('last_played_at', { withTimezone: true }),
addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Song = typeof songs.$inferSelect;
export type NewSong = typeof songs.$inferInsert;

View file

@ -0,0 +1,34 @@
import 'dotenv/config';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
async function main() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
const connection = postgres(databaseUrl, { max: 1 });
const db = drizzle(connection, { schema });
console.log('Seeding database...');
// Add seed data here if needed
// Example:
// await db.insert(schema.projects).values({
// userId: 'test-user',
// title: 'Demo Project',
// description: 'A demo project for testing',
// });
console.log('Seeding complete!');
await connection.end();
process.exit(0);
}
main().catch((err) => {
console.error('Seeding failed:', err);
process.exit(1);
});

View file

@ -0,0 +1,25 @@
import { Controller, Get, Param, Query, UseGuards, ParseUUIDPipe, Res } from '@nestjs/common';
import { Response } from 'express';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { ExportService } from './export.service';
import type { ExportFormat } from '@mukke/shared';
@Controller('export')
@UseGuards(JwtAuthGuard)
export class ExportController {
constructor(private readonly exportService: ExportService) {}
@Get(':projectId')
async exportProject(
@CurrentUser() user: CurrentUserData,
@Param('projectId', ParseUUIDPipe) projectId: string,
@Query('format') format: ExportFormat = 'json',
@Res() res: Response
) {
const result = await this.exportService.exportProject(projectId, user.userId, format);
res.setHeader('Content-Type', result.contentType);
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
res.send(result.content);
}
}

View file

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { ExportController } from './export.controller';
import { ExportService } from './export.service';
import { ProjectModule } from '../project/project.module';
import { BeatModule } from '../beat/beat.module';
import { MarkerModule } from '../marker/marker.module';
import { LyricsModule } from '../lyrics/lyrics.module';
@Module({
imports: [ProjectModule, BeatModule, MarkerModule, LyricsModule],
controllers: [ExportController],
providers: [ExportService],
exports: [ExportService],
})
export class ExportModule {}

View file

@ -0,0 +1,173 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { ProjectService } from '../project/project.service';
import { BeatService } from '../beat/beat.service';
import { MarkerService } from '../marker/marker.service';
import { LyricsService } from '../lyrics/lyrics.service';
import type { ExportFormat, JsonExportData } from '@mukke/shared';
@Injectable()
export class ExportService {
constructor(
private projectService: ProjectService,
private beatService: BeatService,
private markerService: MarkerService,
private lyricsService: LyricsService
) {}
async exportProject(
projectId: string,
userId: string,
format: ExportFormat
): Promise<{ content: string; filename: string; contentType: string }> {
const project = await this.projectService.findByIdOrThrow(projectId, userId);
const beat = await this.beatService.findByProjectId(projectId);
const lyricsData = await this.lyricsService.getWithLines(projectId, userId);
const markerList = beat ? await this.markerService.findByBeatId(beat.id) : [];
const lines = lyricsData?.lines || [];
const safeTitle = project.title.replace(/[^a-z0-9]/gi, '_').toLowerCase();
switch (format) {
case 'lrc':
return {
content: this.generateLrc(lines, beat?.bpm),
filename: `${safeTitle}.lrc`,
contentType: 'text/plain',
};
case 'srt':
return {
content: this.generateSrt(lines),
filename: `${safeTitle}.srt`,
contentType: 'text/plain',
};
case 'json':
return {
content: this.generateJson(project, beat, markerList, lines),
filename: `${safeTitle}.json`,
contentType: 'application/json',
};
case 'video':
throw new BadRequestException(
'Video export is not yet supported. Use client-side video generation.'
);
default:
throw new BadRequestException(`Unknown export format: ${format}`);
}
}
private formatTime(seconds: number, format: 'lrc' | 'srt'): string {
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
if (format === 'lrc') {
// LRC format: [mm:ss.xx]
const hundredths = Math.round((secs % 1) * 100);
const wholeSecs = Math.floor(secs);
return `[${minutes.toString().padStart(2, '0')}:${wholeSecs.toString().padStart(2, '0')}.${hundredths.toString().padStart(2, '0')}]`;
} else {
// SRT format: hh:mm:ss,mmm
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
const millis = Math.round((secs % 1) * 1000);
const wholeSecs = Math.floor(secs);
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${wholeSecs.toString().padStart(2, '0')},${millis.toString().padStart(3, '0')}`;
}
}
private generateLrc(
lines: Array<{ text: string; startTime?: number | null; lineNumber: number }>,
bpm?: number | null
): string {
const output: string[] = [];
// Add metadata
output.push('[ti:Mukke Export]');
output.push('[ar:Unknown Artist]');
if (bpm) {
output.push(`[bpm:${bpm}]`);
}
output.push('');
// Add synced lines
for (const line of lines) {
if (line.startTime !== null && line.startTime !== undefined) {
const timestamp = this.formatTime(line.startTime, 'lrc');
output.push(`${timestamp}${line.text}`);
} else {
output.push(line.text);
}
}
return output.join('\n');
}
private generateSrt(
lines: Array<{
text: string;
startTime?: number | null;
endTime?: number | null;
lineNumber: number;
}>
): string {
const output: string[] = [];
let index = 1;
for (const line of lines) {
if (line.startTime !== null && line.startTime !== undefined) {
const start = this.formatTime(line.startTime, 'srt');
const end = this.formatTime(line.endTime ?? line.startTime + 3, 'srt');
output.push(index.toString());
output.push(`${start} --> ${end}`);
output.push(line.text);
output.push('');
index++;
}
}
return output.join('\n');
}
private generateJson(
project: { id: string; title: string; description?: string | null },
beat: { bpm?: number | null; duration?: number | null } | null,
markers: Array<{
type: string;
label?: string | null;
startTime: number;
endTime?: number | null;
}>,
lines: Array<{
lineNumber: number;
text: string;
startTime?: number | null;
endTime?: number | null;
}>
): string {
const data: JsonExportData = {
project: {
id: project.id,
title: project.title,
description: project.description || undefined,
},
beat: {
bpm: beat?.bpm || undefined,
duration: beat?.duration || undefined,
},
markers: markers.map((m) => ({
type: m.type,
label: m.label || undefined,
startTime: m.startTime,
endTime: m.endTime || undefined,
})),
lyrics: lines.map((l) => ({
lineNumber: l.lineNumber,
text: l.text,
startTime: l.startTime || undefined,
endTime: l.endTime || undefined,
})),
};
return JSON.stringify(data, null, 2);
}
}

View file

@ -0,0 +1,51 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { LibraryService } from './library.service';
@Controller('library')
@UseGuards(JwtAuthGuard)
export class LibraryController {
constructor(private readonly libraryService: LibraryService) {}
@Get('albums')
async getAlbums(@CurrentUser() user: CurrentUserData) {
const albums = await this.libraryService.getAlbums(user.userId);
return { albums };
}
@Get('artists')
async getArtists(@CurrentUser() user: CurrentUserData) {
const artists = await this.libraryService.getArtists(user.userId);
return { artists };
}
@Get('genres')
async getGenres(@CurrentUser() user: CurrentUserData) {
const genres = await this.libraryService.getGenres(user.userId);
return { genres };
}
@Get('stats')
async getStats(@CurrentUser() user: CurrentUserData) {
const stats = await this.libraryService.getStats(user.userId);
return { stats };
}
@Get('albums/:name')
async getSongsByAlbum(@CurrentUser() user: CurrentUserData, @Param('name') name: string) {
const songs = await this.libraryService.getSongsByAlbum(user.userId, decodeURIComponent(name));
return { songs };
}
@Get('artists/:name')
async getSongsByArtist(@CurrentUser() user: CurrentUserData, @Param('name') name: string) {
const songs = await this.libraryService.getSongsByArtist(user.userId, decodeURIComponent(name));
return { songs };
}
@Get('genres/:name')
async getSongsByGenre(@CurrentUser() user: CurrentUserData, @Param('name') name: string) {
const songs = await this.libraryService.getSongsByGenre(user.userId, decodeURIComponent(name));
return { songs };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LibraryController } from './library.controller';
import { LibraryService } from './library.service';
@Module({
controllers: [LibraryController],
providers: [LibraryService],
exports: [LibraryService],
})
export class LibraryModule {}

View file

@ -0,0 +1,152 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LibraryService } from './library.service';
import { DATABASE_CONNECTION } from '../db/database.module';
import { createMockSong, TEST_USER_ID } from '../__tests__/utils/mock-factories';
describe('LibraryService', () => {
let service: LibraryService;
let mockDb: any;
beforeEach(async () => {
mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
execute: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
LibraryService,
{
provide: DATABASE_CONNECTION,
useValue: mockDb,
},
],
}).compile();
service = module.get<LibraryService>(LibraryService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getAlbums', () => {
it('should return grouped albums for user', async () => {
const albums = [
{ album: 'Album A', albumArtist: 'Artist 1', year: 2020, coverArtPath: null, songCount: 5 },
{ album: 'Album B', albumArtist: 'Artist 2', year: 2022, coverArtPath: null, songCount: 3 },
];
mockDb.execute.mockResolvedValueOnce(albums);
const result = await service.getAlbums(TEST_USER_ID);
expect(result).toEqual(albums);
expect(mockDb.execute).toHaveBeenCalled();
});
});
describe('getArtists', () => {
it('should return grouped artists for user', async () => {
const artists = [
{ artist: 'Artist 1', songCount: 10, albumCount: 2 },
{ artist: 'Artist 2', songCount: 5, albumCount: 1 },
];
mockDb.execute.mockResolvedValueOnce(artists);
const result = await service.getArtists(TEST_USER_ID);
expect(result).toEqual(artists);
expect(mockDb.execute).toHaveBeenCalled();
});
});
describe('getGenres', () => {
it('should return grouped genres for user', async () => {
const genres = [
{ genre: 'Rock', songCount: 15 },
{ genre: 'Jazz', songCount: 8 },
];
mockDb.execute.mockResolvedValueOnce(genres);
const result = await service.getGenres(TEST_USER_ID);
expect(result).toEqual(genres);
expect(mockDb.execute).toHaveBeenCalled();
});
});
describe('getStats', () => {
it('should return library statistics', async () => {
const stats = [
{
totalSongs: 50,
totalArtists: 10,
totalAlbums: 8,
totalGenres: 5,
totalDuration: 12500.5,
totalPlays: 200,
},
];
mockDb.execute.mockResolvedValueOnce(stats);
const result = await service.getStats(TEST_USER_ID);
expect(result).toEqual(stats[0]);
expect(mockDb.execute).toHaveBeenCalled();
});
});
describe('getSongsByAlbum', () => {
it('should return songs for a specific album', async () => {
const songs = [
createMockSong({ title: 'Track 1', album: 'Test Album', trackNumber: 1 }),
createMockSong({ title: 'Track 2', album: 'Test Album', trackNumber: 2 }),
];
mockDb.orderBy.mockResolvedValueOnce(songs);
const result = await service.getSongsByAlbum(TEST_USER_ID, 'Test Album');
expect(result).toEqual(songs);
expect(mockDb.select).toHaveBeenCalled();
expect(mockDb.from).toHaveBeenCalled();
expect(mockDb.where).toHaveBeenCalled();
});
});
describe('getSongsByArtist', () => {
it('should return songs for a specific artist', async () => {
const songs = [
createMockSong({ title: 'Song A', artist: 'Test Artist' }),
createMockSong({ title: 'Song B', artist: 'Test Artist' }),
];
mockDb.orderBy.mockResolvedValueOnce(songs);
const result = await service.getSongsByArtist(TEST_USER_ID, 'Test Artist');
expect(result).toEqual(songs);
expect(mockDb.select).toHaveBeenCalled();
expect(mockDb.from).toHaveBeenCalled();
expect(mockDb.where).toHaveBeenCalled();
});
});
describe('getSongsByGenre', () => {
it('should return songs for a specific genre', async () => {
const songs = [
createMockSong({ title: 'Rock Song 1', genre: 'Rock' }),
createMockSong({ title: 'Rock Song 2', genre: 'Rock' }),
];
mockDb.orderBy.mockResolvedValueOnce(songs);
const result = await service.getSongsByGenre(TEST_USER_ID, 'Rock');
expect(result).toEqual(songs);
expect(mockDb.select).toHaveBeenCalled();
expect(mockDb.from).toHaveBeenCalled();
expect(mockDb.where).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,96 @@
import { Injectable, Inject } from '@nestjs/common';
import { eq, and, asc, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { songs } from '../db/schema';
@Injectable()
export class LibraryService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async getAlbums(userId: string) {
const result = await this.db.execute<{
album: string;
albumArtist: string | null;
year: number | null;
coverArtPath: string | null;
songCount: number;
}>(sql`
SELECT album, album_artist as "albumArtist", MIN(year) as year,
MIN(cover_art_path) as "coverArtPath", COUNT(*)::int as "songCount"
FROM songs WHERE user_id = ${userId} AND album IS NOT NULL
GROUP BY album, album_artist ORDER BY album
`);
return result;
}
async getArtists(userId: string) {
const result = await this.db.execute<{
artist: string;
songCount: number;
albumCount: number;
}>(sql`
SELECT artist, COUNT(*)::int as "songCount",
COUNT(DISTINCT album)::int as "albumCount"
FROM songs WHERE user_id = ${userId} AND artist IS NOT NULL
GROUP BY artist ORDER BY artist
`);
return result;
}
async getGenres(userId: string) {
const result = await this.db.execute<{
genre: string;
songCount: number;
}>(sql`
SELECT genre, COUNT(*)::int as "songCount"
FROM songs WHERE user_id = ${userId} AND genre IS NOT NULL
GROUP BY genre ORDER BY genre
`);
return result;
}
async getStats(userId: string) {
const result = await this.db.execute<{
totalSongs: number;
totalArtists: number;
totalAlbums: number;
totalGenres: number;
totalDuration: number;
totalPlays: number;
}>(sql`
SELECT COUNT(*)::int as "totalSongs",
COUNT(DISTINCT artist)::int as "totalArtists",
COUNT(DISTINCT album)::int as "totalAlbums",
COUNT(DISTINCT genre)::int as "totalGenres",
COALESCE(SUM(duration), 0)::real as "totalDuration",
COALESCE(SUM(play_count), 0)::int as "totalPlays"
FROM songs WHERE user_id = ${userId}
`);
return result[0];
}
async getSongsByAlbum(userId: string, albumName: string) {
return this.db
.select()
.from(songs)
.where(and(eq(songs.userId, userId), eq(songs.album, albumName)))
.orderBy(asc(songs.trackNumber));
}
async getSongsByArtist(userId: string, artistName: string) {
return this.db
.select()
.from(songs)
.where(and(eq(songs.userId, userId), eq(songs.artist, artistName)))
.orderBy(asc(songs.title));
}
async getSongsByGenre(userId: string, genreName: string) {
return this.db
.select()
.from(songs)
.where(and(eq(songs.userId, userId), eq(songs.genre, genreName)))
.orderBy(asc(songs.title));
}
}

View file

@ -0,0 +1,53 @@
import {
IsString,
IsNumber,
IsOptional,
IsArray,
ValidateNested,
IsInt,
Min,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CreateOrUpdateLyricsDto {
@IsString()
content!: string;
}
class LyricLineDto {
@IsInt()
@Min(0)
lineNumber!: number;
@IsString()
text!: string;
@IsNumber()
@IsOptional()
@Min(0)
startTime?: number;
@IsNumber()
@IsOptional()
@Min(0)
endTime?: number;
}
export class SyncLinesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => LyricLineDto)
lines!: LyricLineDto[];
}
export class UpdateLineTimestampDto {
@IsNumber()
@IsOptional()
@Min(0)
startTime?: number;
@IsNumber()
@IsOptional()
@Min(0)
endTime?: number;
}

View file

@ -0,0 +1,53 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards, ParseUUIDPipe } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { LyricsService } from './lyrics.service';
import { CreateOrUpdateLyricsDto, SyncLinesDto, UpdateLineTimestampDto } from './dto/lyrics.dto';
@Controller('lyrics')
@UseGuards(JwtAuthGuard)
export class LyricsController {
constructor(private readonly lyricsService: LyricsService) {}
@Get('project/:projectId')
async findByProject(
@CurrentUser() user: CurrentUserData,
@Param('projectId', ParseUUIDPipe) projectId: string
) {
const result = await this.lyricsService.getWithLines(projectId, user.userId);
return { lyrics: result };
}
@Post('project/:projectId')
async createOrUpdate(
@CurrentUser() user: CurrentUserData,
@Param('projectId', ParseUUIDPipe) projectId: string,
@Body() dto: CreateOrUpdateLyricsDto
) {
const lyricsRecord = await this.lyricsService.createOrUpdate(
projectId,
user.userId,
dto.content
);
return { lyrics: lyricsRecord };
}
@Post(':id/sync')
async syncLines(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SyncLinesDto
) {
const lines = await this.lyricsService.syncLines(id, user.userId, dto.lines);
return { lines };
}
@Put('line/:lineId/timestamp')
async updateLineTimestamp(
@CurrentUser() user: CurrentUserData,
@Param('lineId', ParseUUIDPipe) lineId: string,
@Body() dto: UpdateLineTimestampDto
) {
const line = await this.lyricsService.updateLineTimestamp(lineId, user.userId, dto);
return { line };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LyricsController } from './lyrics.controller';
import { LyricsService } from './lyrics.service';
@Module({
controllers: [LyricsController],
providers: [LyricsService],
exports: [LyricsService],
})
export class LyricsModule {}

View file

@ -0,0 +1,133 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, asc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { lyrics, lyricLines, projects } from '../db/schema';
import type { Lyrics, NewLyrics, LyricLine, NewLyricLine } from '../db/schema';
@Injectable()
export class LyricsService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
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 findByProjectId(projectId: string): Promise<Lyrics | null> {
const [lyricsRecord] = await this.db
.select()
.from(lyrics)
.where(eq(lyrics.projectId, projectId));
return lyricsRecord || null;
}
async findById(id: string): Promise<Lyrics | null> {
const [lyricsRecord] = await this.db.select().from(lyrics).where(eq(lyrics.id, id));
return lyricsRecord || null;
}
async findByIdOrThrow(id: string): Promise<Lyrics> {
const lyricsRecord = await this.findById(id);
if (!lyricsRecord) {
throw new NotFoundException('Lyrics not found');
}
return lyricsRecord;
}
async createOrUpdate(projectId: string, userId: string, content: string): Promise<Lyrics> {
await this.verifyProjectOwnership(projectId, userId);
const existing = await this.findByProjectId(projectId);
if (existing) {
const [updated] = await this.db
.update(lyrics)
.set({ content })
.where(eq(lyrics.id, existing.id))
.returning();
return updated;
}
const [created] = await this.db.insert(lyrics).values({ projectId, content }).returning();
return created;
}
async getLinesForLyrics(lyricsId: string): Promise<LyricLine[]> {
return this.db
.select()
.from(lyricLines)
.where(eq(lyricLines.lyricsId, lyricsId))
.orderBy(asc(lyricLines.lineNumber));
}
async syncLines(
lyricsId: string,
userId: string,
lines: Array<{
lineNumber: number;
text: string;
startTime?: number;
endTime?: number;
}>
): Promise<LyricLine[]> {
const lyricsRecord = await this.findByIdOrThrow(lyricsId);
await this.verifyProjectOwnership(lyricsRecord.projectId, userId);
// Delete existing lines
await this.db.delete(lyricLines).where(eq(lyricLines.lyricsId, lyricsId));
if (lines.length === 0) return [];
// Insert new lines
const values: NewLyricLine[] = lines.map((line) => ({
lyricsId,
lineNumber: line.lineNumber,
text: line.text,
startTime: line.startTime,
endTime: line.endTime,
}));
return this.db.insert(lyricLines).values(values).returning();
}
async updateLineTimestamp(
lineId: string,
userId: string,
data: { startTime?: number; endTime?: number }
): Promise<LyricLine> {
const [line] = await this.db.select().from(lyricLines).where(eq(lyricLines.id, lineId));
if (!line) {
throw new NotFoundException('Lyric line not found');
}
const lyricsRecord = await this.findByIdOrThrow(line.lyricsId);
await this.verifyProjectOwnership(lyricsRecord.projectId, userId);
const [updated] = await this.db
.update(lyricLines)
.set(data)
.where(eq(lyricLines.id, lineId))
.returning();
return updated;
}
async getWithLines(projectId: string, userId: string) {
await this.verifyProjectOwnership(projectId, userId);
const lyricsRecord = await this.findByProjectId(projectId);
if (!lyricsRecord) {
return null;
}
const lines = await this.getLinesForLyrics(lyricsRecord.id);
return {
...lyricsRecord,
lines,
};
}
}

View file

@ -0,0 +1,8 @@
import { bootstrapApp } from '@manacore/shared-nestjs-setup';
import { AppModule } from './app.module';
bootstrapApp(AppModule, {
defaultPort: 3010,
serviceName: 'Mukke',
additionalCorsOrigins: ['http://localhost:5180'],
});

Some files were not shown because too many files have changed in this diff Show more