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>
|
|
@ -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**
|
||||
|
|
|
|||
60
apps-archived/mukke/CLAUDE.md
Normal 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
|
||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 115 B After Width: | Height: | Size: 115 B |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
9
apps-archived/mukke/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
19
apps/mukke/apps/backend/.env.example
Normal 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
|
||||
82
apps/mukke/apps/backend/Dockerfile
Normal 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"]
|
||||
46
apps/mukke/apps/backend/docker-entrypoint.sh
Normal 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 "$@"
|
||||
6
apps/mukke/apps/backend/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
||||
|
||||
export default createDrizzleConfig({
|
||||
dbName: 'mukke',
|
||||
additionalEnvVars: ['MUKKE_DATABASE_URL'],
|
||||
});
|
||||
24
apps/mukke/apps/backend/jest.config.js
Normal 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)/)'],
|
||||
};
|
||||
8
apps/mukke/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
66
apps/mukke/apps/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
34
apps/mukke/apps/backend/src/app.module.ts
Normal 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 {}
|
||||
128
apps/mukke/apps/backend/src/beat/beat.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
13
apps/mukke/apps/backend/src/beat/beat.module.ts
Normal 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 {}
|
||||
267
apps/mukke/apps/backend/src/beat/beat.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
apps/mukke/apps/backend/src/beat/dto/beat.dto.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
38
apps/mukke/apps/backend/src/db/connection.ts
Normal 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>;
|
||||
29
apps/mukke/apps/backend/src/db/database.module.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
26
apps/mukke/apps/backend/src/db/migrate.ts
Normal 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);
|
||||
});
|
||||
23
apps/mukke/apps/backend/src/db/schema/beats.schema.ts
Normal 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;
|
||||
7
apps/mukke/apps/backend/src/db/schema/index.ts
Normal 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';
|
||||
|
|
@ -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;
|
||||
27
apps/mukke/apps/backend/src/db/schema/lyrics.schema.ts
Normal 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;
|
||||
18
apps/mukke/apps/backend/src/db/schema/markers.schema.ts
Normal 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;
|
||||
29
apps/mukke/apps/backend/src/db/schema/playlists.schema.ts
Normal 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;
|
||||
15
apps/mukke/apps/backend/src/db/schema/projects.schema.ts
Normal 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;
|
||||
35
apps/mukke/apps/backend/src/db/schema/songs.schema.ts
Normal 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;
|
||||
34
apps/mukke/apps/backend/src/db/seed.ts
Normal 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);
|
||||
});
|
||||
25
apps/mukke/apps/backend/src/export/export.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
apps/mukke/apps/backend/src/export/export.module.ts
Normal 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 {}
|
||||
173
apps/mukke/apps/backend/src/export/export.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
51
apps/mukke/apps/backend/src/library/library.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
apps/mukke/apps/backend/src/library/library.module.ts
Normal 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 {}
|
||||
152
apps/mukke/apps/backend/src/library/library.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
96
apps/mukke/apps/backend/src/library/library.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
53
apps/mukke/apps/backend/src/lyrics/dto/lyrics.dto.ts
Normal 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;
|
||||
}
|
||||
53
apps/mukke/apps/backend/src/lyrics/lyrics.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
apps/mukke/apps/backend/src/lyrics/lyrics.module.ts
Normal 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 {}
|
||||
133
apps/mukke/apps/backend/src/lyrics/lyrics.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
8
apps/mukke/apps/backend/src/main.ts
Normal 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'],
|
||||
});
|
||||