diff --git a/.claude/guidelines/authentication.md b/.claude/guidelines/authentication.md index 34d9c3dda..483706136 100644 --- a/.claude/guidelines/authentication.md +++ b/.claude/guidelines/authentication.md @@ -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 + + + +``` + +### Forgot Password Page Template + +```svelte + + + +``` + +### 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** diff --git a/apps-archived/mukke/CLAUDE.md b/apps-archived/mukke/CLAUDE.md new file mode 100644 index 000000000..b586e5bbe --- /dev/null +++ b/apps-archived/mukke/CLAUDE.md @@ -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 diff --git a/apps/mukke/apps/mobile/.gitignore b/apps-archived/mukke/apps/mobile/.gitignore similarity index 100% rename from apps/mukke/apps/mobile/.gitignore rename to apps-archived/mukke/apps/mobile/.gitignore diff --git a/apps/mukke/apps/mobile/app.json b/apps-archived/mukke/apps/mobile/app.json similarity index 100% rename from apps/mukke/apps/mobile/app.json rename to apps-archived/mukke/apps/mobile/app.json diff --git a/apps/mukke/apps/mobile/app/(tabs)/_layout.tsx b/apps-archived/mukke/apps/mobile/app/(tabs)/_layout.tsx similarity index 100% rename from apps/mukke/apps/mobile/app/(tabs)/_layout.tsx rename to apps-archived/mukke/apps/mobile/app/(tabs)/_layout.tsx diff --git a/apps/mukke/apps/mobile/app/(tabs)/index.tsx b/apps-archived/mukke/apps/mobile/app/(tabs)/index.tsx similarity index 100% rename from apps/mukke/apps/mobile/app/(tabs)/index.tsx rename to apps-archived/mukke/apps/mobile/app/(tabs)/index.tsx diff --git a/apps/mukke/apps/mobile/app/(tabs)/playlists.tsx b/apps-archived/mukke/apps/mobile/app/(tabs)/playlists.tsx similarity index 100% rename from apps/mukke/apps/mobile/app/(tabs)/playlists.tsx rename to apps-archived/mukke/apps/mobile/app/(tabs)/playlists.tsx diff --git a/apps/mukke/apps/mobile/app/(tabs)/search.tsx b/apps-archived/mukke/apps/mobile/app/(tabs)/search.tsx similarity index 100% rename from apps/mukke/apps/mobile/app/(tabs)/search.tsx rename to apps-archived/mukke/apps/mobile/app/(tabs)/search.tsx diff --git a/apps/mukke/apps/mobile/app/(tabs)/settings.tsx b/apps-archived/mukke/apps/mobile/app/(tabs)/settings.tsx similarity index 100% rename from apps/mukke/apps/mobile/app/(tabs)/settings.tsx rename to apps-archived/mukke/apps/mobile/app/(tabs)/settings.tsx diff --git a/apps/mukke/apps/mobile/app/+not-found.tsx b/apps-archived/mukke/apps/mobile/app/+not-found.tsx similarity index 100% rename from apps/mukke/apps/mobile/app/+not-found.tsx rename to apps-archived/mukke/apps/mobile/app/+not-found.tsx diff --git a/apps/mukke/apps/mobile/app/_layout.tsx b/apps-archived/mukke/apps/mobile/app/_layout.tsx similarity index 100% rename from apps/mukke/apps/mobile/app/_layout.tsx rename to apps-archived/mukke/apps/mobile/app/_layout.tsx diff --git a/apps/mukke/apps/mobile/app/album/[id].tsx b/apps-archived/mukke/apps/mobile/app/album/[id].tsx similarity index 100% rename from apps/mukke/apps/mobile/app/album/[id].tsx rename to apps-archived/mukke/apps/mobile/app/album/[id].tsx diff --git a/apps/mukke/apps/mobile/app/artist/[id].tsx b/apps-archived/mukke/apps/mobile/app/artist/[id].tsx similarity index 100% rename from apps/mukke/apps/mobile/app/artist/[id].tsx rename to apps-archived/mukke/apps/mobile/app/artist/[id].tsx diff --git a/apps/mukke/apps/mobile/app/player.tsx b/apps-archived/mukke/apps/mobile/app/player.tsx similarity index 100% rename from apps/mukke/apps/mobile/app/player.tsx rename to apps-archived/mukke/apps/mobile/app/player.tsx diff --git a/apps/mukke/apps/mobile/app/playlist/[id].tsx b/apps-archived/mukke/apps/mobile/app/playlist/[id].tsx similarity index 100% rename from apps/mukke/apps/mobile/app/playlist/[id].tsx rename to apps-archived/mukke/apps/mobile/app/playlist/[id].tsx diff --git a/apps/mukke/apps/mobile/app/playlist/new.tsx b/apps-archived/mukke/apps/mobile/app/playlist/new.tsx similarity index 100% rename from apps/mukke/apps/mobile/app/playlist/new.tsx rename to apps-archived/mukke/apps/mobile/app/playlist/new.tsx diff --git a/apps/mukke/apps/mobile/app/queue.tsx b/apps-archived/mukke/apps/mobile/app/queue.tsx similarity index 100% rename from apps/mukke/apps/mobile/app/queue.tsx rename to apps-archived/mukke/apps/mobile/app/queue.tsx diff --git a/apps/mukke/apps/mobile/assets/adaptive-icon.png b/apps-archived/mukke/apps/mobile/assets/adaptive-icon.png similarity index 100% rename from apps/mukke/apps/mobile/assets/adaptive-icon.png rename to apps-archived/mukke/apps/mobile/assets/adaptive-icon.png diff --git a/apps/mukke/apps/mobile/assets/favicon.png b/apps-archived/mukke/apps/mobile/assets/favicon.png similarity index 100% rename from apps/mukke/apps/mobile/assets/favicon.png rename to apps-archived/mukke/apps/mobile/assets/favicon.png diff --git a/apps/mukke/apps/mobile/assets/icon.png b/apps-archived/mukke/apps/mobile/assets/icon.png similarity index 100% rename from apps/mukke/apps/mobile/assets/icon.png rename to apps-archived/mukke/apps/mobile/assets/icon.png diff --git a/apps/mukke/apps/mobile/assets/splash.png b/apps-archived/mukke/apps/mobile/assets/splash.png similarity index 100% rename from apps/mukke/apps/mobile/assets/splash.png rename to apps-archived/mukke/apps/mobile/assets/splash.png diff --git a/apps/mukke/apps/mobile/babel.config.js b/apps-archived/mukke/apps/mobile/babel.config.js similarity index 100% rename from apps/mukke/apps/mobile/babel.config.js rename to apps-archived/mukke/apps/mobile/babel.config.js diff --git a/apps/mukke/apps/mobile/components/AlbumGrid.tsx b/apps-archived/mukke/apps/mobile/components/AlbumGrid.tsx similarity index 100% rename from apps/mukke/apps/mobile/components/AlbumGrid.tsx rename to apps-archived/mukke/apps/mobile/components/AlbumGrid.tsx diff --git a/apps/mukke/apps/mobile/components/ArtistList.tsx b/apps-archived/mukke/apps/mobile/components/ArtistList.tsx similarity index 100% rename from apps/mukke/apps/mobile/components/ArtistList.tsx rename to apps-archived/mukke/apps/mobile/components/ArtistList.tsx diff --git a/apps/mukke/apps/mobile/components/Artwork.tsx b/apps-archived/mukke/apps/mobile/components/Artwork.tsx similarity index 100% rename from apps/mukke/apps/mobile/components/Artwork.tsx rename to apps-archived/mukke/apps/mobile/components/Artwork.tsx diff --git a/apps/mukke/apps/mobile/components/Button.tsx b/apps-archived/mukke/apps/mobile/components/Button.tsx similarity index 100% rename from apps/mukke/apps/mobile/components/Button.tsx rename to apps-archived/mukke/apps/mobile/components/Button.tsx diff --git a/apps/mukke/apps/mobile/components/EmptyState.tsx b/apps-archived/mukke/apps/mobile/components/EmptyState.tsx similarity index 100% rename from apps/mukke/apps/mobile/components/EmptyState.tsx rename to apps-archived/mukke/apps/mobile/components/EmptyState.tsx diff --git a/apps/mukke/apps/mobile/components/GenreList.tsx b/apps-archived/mukke/apps/mobile/components/GenreList.tsx similarity index 100% rename from apps/mukke/apps/mobile/components/GenreList.tsx rename to apps-archived/mukke/apps/mobile/components/GenreList.tsx diff --git a/apps/mukke/apps/mobile/components/ImportButton.tsx b/apps-archived/mukke/apps/mobile/components/ImportButton.tsx similarity index 100% rename from apps/mukke/apps/mobile/components/ImportButton.tsx rename to apps-archived/mukke/apps/mobile/components/ImportButton.tsx diff --git a/apps/mukke/apps/mobile/components/ListItem.tsx b/apps-archived/mukke/apps/mobile/components/ListItem.tsx similarity index 100% rename from apps/mukke/apps/mobile/components/ListItem.tsx rename to apps-archived/mukke/apps/mobile/components/ListItem.tsx diff --git a/apps/mukke/apps/mobile/components/MiniPlayer.tsx b/apps-archived/mukke/apps/mobile/components/MiniPlayer.tsx similarity index 100% rename from apps/mukke/apps/mobile/components/MiniPlayer.tsx rename to apps-archived/mukke/apps/mobile/components/MiniPlayer.tsx diff --git a/apps/mukke/apps/mobile/components/ProgressBar.tsx b/apps-archived/mukke/apps/mobile/components/ProgressBar.tsx similarity index 100% rename from apps/mukke/apps/mobile/components/ProgressBar.tsx rename to apps-archived/mukke/apps/mobile/components/ProgressBar.tsx diff --git a/apps/mukke/apps/mobile/components/SegmentedControl.tsx b/apps-archived/mukke/apps/mobile/components/SegmentedControl.tsx similarity index 100% rename from apps/mukke/apps/mobile/components/SegmentedControl.tsx rename to apps-archived/mukke/apps/mobile/components/SegmentedControl.tsx diff --git a/apps/mukke/apps/mobile/components/SongList.tsx b/apps-archived/mukke/apps/mobile/components/SongList.tsx similarity index 100% rename from apps/mukke/apps/mobile/components/SongList.tsx rename to apps-archived/mukke/apps/mobile/components/SongList.tsx diff --git a/apps/mukke/apps/mobile/components/SongPicker.tsx b/apps-archived/mukke/apps/mobile/components/SongPicker.tsx similarity index 100% rename from apps/mukke/apps/mobile/components/SongPicker.tsx rename to apps-archived/mukke/apps/mobile/components/SongPicker.tsx diff --git a/apps/mukke/apps/mobile/components/SortMenu.tsx b/apps-archived/mukke/apps/mobile/components/SortMenu.tsx similarity index 100% rename from apps/mukke/apps/mobile/components/SortMenu.tsx rename to apps-archived/mukke/apps/mobile/components/SortMenu.tsx diff --git a/apps/mukke/apps/mobile/components/ThemeWrapper.tsx b/apps-archived/mukke/apps/mobile/components/ThemeWrapper.tsx similarity index 100% rename from apps/mukke/apps/mobile/components/ThemeWrapper.tsx rename to apps-archived/mukke/apps/mobile/components/ThemeWrapper.tsx diff --git a/apps/mukke/apps/mobile/components/TransportControls.tsx b/apps-archived/mukke/apps/mobile/components/TransportControls.tsx similarity index 100% rename from apps/mukke/apps/mobile/components/TransportControls.tsx rename to apps-archived/mukke/apps/mobile/components/TransportControls.tsx diff --git a/apps/mukke/apps/mobile/contexts/AudioContext.tsx b/apps-archived/mukke/apps/mobile/contexts/AudioContext.tsx similarity index 100% rename from apps/mukke/apps/mobile/contexts/AudioContext.tsx rename to apps-archived/mukke/apps/mobile/contexts/AudioContext.tsx diff --git a/apps/mukke/apps/mobile/eas.json b/apps-archived/mukke/apps/mobile/eas.json similarity index 100% rename from apps/mukke/apps/mobile/eas.json rename to apps-archived/mukke/apps/mobile/eas.json diff --git a/apps/mukke/apps/mobile/global.css b/apps-archived/mukke/apps/mobile/global.css similarity index 100% rename from apps/mukke/apps/mobile/global.css rename to apps-archived/mukke/apps/mobile/global.css diff --git a/apps/mukke/apps/mobile/metro.config.js b/apps-archived/mukke/apps/mobile/metro.config.js similarity index 100% rename from apps/mukke/apps/mobile/metro.config.js rename to apps-archived/mukke/apps/mobile/metro.config.js diff --git a/apps/mukke/apps/mobile/nativewind-env.d.ts b/apps-archived/mukke/apps/mobile/nativewind-env.d.ts similarity index 100% rename from apps/mukke/apps/mobile/nativewind-env.d.ts rename to apps-archived/mukke/apps/mobile/nativewind-env.d.ts diff --git a/apps/mukke/apps/mobile/package.json b/apps-archived/mukke/apps/mobile/package.json similarity index 100% rename from apps/mukke/apps/mobile/package.json rename to apps-archived/mukke/apps/mobile/package.json diff --git a/apps/mukke/apps/mobile/services/audioService.ts b/apps-archived/mukke/apps/mobile/services/audioService.ts similarity index 100% rename from apps/mukke/apps/mobile/services/audioService.ts rename to apps-archived/mukke/apps/mobile/services/audioService.ts diff --git a/apps/mukke/apps/mobile/services/database.ts b/apps-archived/mukke/apps/mobile/services/database.ts similarity index 100% rename from apps/mukke/apps/mobile/services/database.ts rename to apps-archived/mukke/apps/mobile/services/database.ts diff --git a/apps/mukke/apps/mobile/services/fileService.ts b/apps-archived/mukke/apps/mobile/services/fileService.ts similarity index 100% rename from apps/mukke/apps/mobile/services/fileService.ts rename to apps-archived/mukke/apps/mobile/services/fileService.ts diff --git a/apps/mukke/apps/mobile/services/importService.ts b/apps-archived/mukke/apps/mobile/services/importService.ts similarity index 100% rename from apps/mukke/apps/mobile/services/importService.ts rename to apps-archived/mukke/apps/mobile/services/importService.ts diff --git a/apps/mukke/apps/mobile/services/libraryService.ts b/apps-archived/mukke/apps/mobile/services/libraryService.ts similarity index 100% rename from apps/mukke/apps/mobile/services/libraryService.ts rename to apps-archived/mukke/apps/mobile/services/libraryService.ts diff --git a/apps/mukke/apps/mobile/services/playlistService.ts b/apps-archived/mukke/apps/mobile/services/playlistService.ts similarity index 100% rename from apps/mukke/apps/mobile/services/playlistService.ts rename to apps-archived/mukke/apps/mobile/services/playlistService.ts diff --git a/apps/mukke/apps/mobile/services/queueService.ts b/apps-archived/mukke/apps/mobile/services/queueService.ts similarity index 100% rename from apps/mukke/apps/mobile/services/queueService.ts rename to apps-archived/mukke/apps/mobile/services/queueService.ts diff --git a/apps/mukke/apps/mobile/stores/libraryStore.ts b/apps-archived/mukke/apps/mobile/stores/libraryStore.ts similarity index 100% rename from apps/mukke/apps/mobile/stores/libraryStore.ts rename to apps-archived/mukke/apps/mobile/stores/libraryStore.ts diff --git a/apps/mukke/apps/mobile/stores/playerStore.ts b/apps-archived/mukke/apps/mobile/stores/playerStore.ts similarity index 100% rename from apps/mukke/apps/mobile/stores/playerStore.ts rename to apps-archived/mukke/apps/mobile/stores/playerStore.ts diff --git a/apps/mukke/apps/mobile/stores/playlistStore.ts b/apps-archived/mukke/apps/mobile/stores/playlistStore.ts similarity index 100% rename from apps/mukke/apps/mobile/stores/playlistStore.ts rename to apps-archived/mukke/apps/mobile/stores/playlistStore.ts diff --git a/apps/mukke/apps/mobile/tailwind.config.js b/apps-archived/mukke/apps/mobile/tailwind.config.js similarity index 100% rename from apps/mukke/apps/mobile/tailwind.config.js rename to apps-archived/mukke/apps/mobile/tailwind.config.js diff --git a/apps/mukke/apps/mobile/tsconfig.json b/apps-archived/mukke/apps/mobile/tsconfig.json similarity index 100% rename from apps/mukke/apps/mobile/tsconfig.json rename to apps-archived/mukke/apps/mobile/tsconfig.json diff --git a/apps/mukke/apps/mobile/types/index.ts b/apps-archived/mukke/apps/mobile/types/index.ts similarity index 100% rename from apps/mukke/apps/mobile/types/index.ts rename to apps-archived/mukke/apps/mobile/types/index.ts diff --git a/apps/mukke/apps/mobile/utils/themeContext.tsx b/apps-archived/mukke/apps/mobile/utils/themeContext.tsx similarity index 100% rename from apps/mukke/apps/mobile/utils/themeContext.tsx rename to apps-archived/mukke/apps/mobile/utils/themeContext.tsx diff --git a/apps-archived/mukke/package.json b/apps-archived/mukke/package.json new file mode 100644 index 000000000..44052b0ab --- /dev/null +++ b/apps-archived/mukke/package.json @@ -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" + } +} diff --git a/apps/mukke/packages/mukke-types/package.json b/apps-archived/mukke/packages/mukke-types/package.json similarity index 100% rename from apps/mukke/packages/mukke-types/package.json rename to apps-archived/mukke/packages/mukke-types/package.json diff --git a/apps/mukke/packages/mukke-types/src/index.ts b/apps-archived/mukke/packages/mukke-types/src/index.ts similarity index 100% rename from apps/mukke/packages/mukke-types/src/index.ts rename to apps-archived/mukke/packages/mukke-types/src/index.ts diff --git a/apps/mukke/packages/mukke-types/tsconfig.json b/apps-archived/mukke/packages/mukke-types/tsconfig.json similarity index 100% rename from apps/mukke/packages/mukke-types/tsconfig.json rename to apps-archived/mukke/packages/mukke-types/tsconfig.json diff --git a/apps/mukke/CLAUDE.md b/apps/mukke/CLAUDE.md index b586e5bbe..63a1659eb 100644 --- a/apps/mukke/CLAUDE.md +++ b/apps/mukke/CLAUDE.md @@ -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 +``` diff --git a/apps/mukke/apps/backend/.env.example b/apps/mukke/apps/backend/.env.example new file mode 100644 index 000000000..73d64d4c2 --- /dev/null +++ b/apps/mukke/apps/backend/.env.example @@ -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 diff --git a/apps/mukke/apps/backend/Dockerfile b/apps/mukke/apps/backend/Dockerfile new file mode 100644 index 000000000..5e830e5a8 --- /dev/null +++ b/apps/mukke/apps/backend/Dockerfile @@ -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"] diff --git a/apps/mukke/apps/backend/docker-entrypoint.sh b/apps/mukke/apps/backend/docker-entrypoint.sh new file mode 100644 index 000000000..c7752a8d6 --- /dev/null +++ b/apps/mukke/apps/backend/docker-entrypoint.sh @@ -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 "$@" diff --git a/apps/mukke/apps/backend/drizzle.config.ts b/apps/mukke/apps/backend/drizzle.config.ts new file mode 100644 index 000000000..404a7e253 --- /dev/null +++ b/apps/mukke/apps/backend/drizzle.config.ts @@ -0,0 +1,6 @@ +import { createDrizzleConfig } from '@manacore/shared-drizzle-config'; + +export default createDrizzleConfig({ + dbName: 'mukke', + additionalEnvVars: ['MUKKE_DATABASE_URL'], +}); diff --git a/apps/mukke/apps/backend/jest.config.js b/apps/mukke/apps/backend/jest.config.js new file mode 100644 index 000000000..29828e8ca --- /dev/null +++ b/apps/mukke/apps/backend/jest.config.js @@ -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$': '/../../packages/shared/src', + }, + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, + transformIgnorePatterns: ['node_modules/(?!(@mukke|@manacore)/)'], +}; diff --git a/apps/mukke/apps/backend/nest-cli.json b/apps/mukke/apps/backend/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/apps/mukke/apps/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/mukke/apps/backend/package.json b/apps/mukke/apps/backend/package.json new file mode 100644 index 000000000..a13da13ad --- /dev/null +++ b/apps/mukke/apps/backend/package.json @@ -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" + } +} diff --git a/apps/mukke/apps/backend/src/__tests__/utils/mock-factories.ts b/apps/mukke/apps/backend/src/__tests__/utils/mock-factories.ts new file mode 100644 index 000000000..284ab8afc --- /dev/null +++ b/apps/mukke/apps/backend/src/__tests__/utils/mock-factories.ts @@ -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 { + 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 { + 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 { + return { + id: crypto.randomUUID(), + playlistId: crypto.randomUUID(), + songId: crypto.randomUUID(), + sortOrder: 0, + addedAt: new Date(), + ...overrides, + }; +} diff --git a/apps/mukke/apps/backend/src/app.module.ts b/apps/mukke/apps/backend/src/app.module.ts new file mode 100644 index 000000000..8f8ba928f --- /dev/null +++ b/apps/mukke/apps/backend/src/app.module.ts @@ -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 {} diff --git a/apps/mukke/apps/backend/src/beat/beat.controller.ts b/apps/mukke/apps/backend/src/beat/beat.controller.ts new file mode 100644 index 000000000..7656dee61 --- /dev/null +++ b/apps/mukke/apps/backend/src/beat/beat.controller.ts @@ -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; + } +} diff --git a/apps/mukke/apps/backend/src/beat/beat.module.ts b/apps/mukke/apps/backend/src/beat/beat.module.ts new file mode 100644 index 000000000..026443f44 --- /dev/null +++ b/apps/mukke/apps/backend/src/beat/beat.module.ts @@ -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 {} diff --git a/apps/mukke/apps/backend/src/beat/beat.service.ts b/apps/mukke/apps/backend/src/beat/beat.service.ts new file mode 100644 index 000000000..f580c4c35 --- /dev/null +++ b/apps/mukke/apps/backend/src/beat/beat.service.ts @@ -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 { + const [beat] = await this.db.select().from(beats).where(eq(beats.projectId, projectId)); + return beat || null; + } + + async findById(id: string): Promise { + const [beat] = await this.db.select().from(beats).where(eq(beats.id, id)); + return beat || null; + } + + async findByIdOrThrow(id: string): Promise { + const beat = await this.findById(id); + if (!beat) { + throw new NotFoundException('Beat not found'); + } + return beat; + } + + async verifyProjectOwnership(projectId: string, userId: string): Promise { + 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 { + 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 { + 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 { + 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 { + return this.db.select().from(markers).where(eq(markers.beatId, beatId)); + } + + // ==================== Library Beats ==================== + + async getLibraryBeats(): Promise { + return this.db + .select() + .from(libraryBeats) + .where(eq(libraryBeats.isActive, true)) + .orderBy(libraryBeats.title); + } + + async getLibraryBeatById(id: string): Promise { + const [beat] = await this.db.select().from(libraryBeats).where(eq(libraryBeats.id, id)); + return beat || null; + } + + async getLibraryBeatDownloadUrl(id: string): Promise { + 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 { + 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 { + 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; + } + } +} diff --git a/apps/mukke/apps/backend/src/beat/dto/beat.dto.ts b/apps/mukke/apps/backend/src/beat/dto/beat.dto.ts new file mode 100644 index 000000000..c32925b5b --- /dev/null +++ b/apps/mukke/apps/backend/src/beat/dto/beat.dto.ts @@ -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; + }; +} diff --git a/apps/mukke/apps/backend/src/db/connection.ts b/apps/mukke/apps/backend/src/db/connection.ts new file mode 100644 index 000000000..fccc63f4a --- /dev/null +++ b/apps/mukke/apps/backend/src/db/connection.ts @@ -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 | null = null; +let db: ReturnType | 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; diff --git a/apps/mukke/apps/backend/src/db/database.module.ts b/apps/mukke/apps/backend/src/db/database.module.ts new file mode 100644 index 000000000..5a0a033b3 --- /dev/null +++ b/apps/mukke/apps/backend/src/db/database.module.ts @@ -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('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(); + } +} diff --git a/apps/mukke/apps/backend/src/db/migrate.ts b/apps/mukke/apps/backend/src/db/migrate.ts new file mode 100644 index 000000000..902f9f6a8 --- /dev/null +++ b/apps/mukke/apps/backend/src/db/migrate.ts @@ -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); +}); diff --git a/apps/mukke/apps/backend/src/db/schema/beats.schema.ts b/apps/mukke/apps/backend/src/db/schema/beats.schema.ts new file mode 100644 index 000000000..dd29cca1b --- /dev/null +++ b/apps/mukke/apps/backend/src/db/schema/beats.schema.ts @@ -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; diff --git a/apps/mukke/apps/backend/src/db/schema/index.ts b/apps/mukke/apps/backend/src/db/schema/index.ts new file mode 100644 index 000000000..eaa72724e --- /dev/null +++ b/apps/mukke/apps/backend/src/db/schema/index.ts @@ -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'; diff --git a/apps/mukke/apps/backend/src/db/schema/library-beats.schema.ts b/apps/mukke/apps/backend/src/db/schema/library-beats.schema.ts new file mode 100644 index 000000000..e500a85e0 --- /dev/null +++ b/apps/mukke/apps/backend/src/db/schema/library-beats.schema.ts @@ -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; diff --git a/apps/mukke/apps/backend/src/db/schema/lyrics.schema.ts b/apps/mukke/apps/backend/src/db/schema/lyrics.schema.ts new file mode 100644 index 000000000..1a4195f43 --- /dev/null +++ b/apps/mukke/apps/backend/src/db/schema/lyrics.schema.ts @@ -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; diff --git a/apps/mukke/apps/backend/src/db/schema/markers.schema.ts b/apps/mukke/apps/backend/src/db/schema/markers.schema.ts new file mode 100644 index 000000000..d39cef761 --- /dev/null +++ b/apps/mukke/apps/backend/src/db/schema/markers.schema.ts @@ -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; diff --git a/apps/mukke/apps/backend/src/db/schema/playlists.schema.ts b/apps/mukke/apps/backend/src/db/schema/playlists.schema.ts new file mode 100644 index 000000000..7398e7ebd --- /dev/null +++ b/apps/mukke/apps/backend/src/db/schema/playlists.schema.ts @@ -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; diff --git a/apps/mukke/apps/backend/src/db/schema/projects.schema.ts b/apps/mukke/apps/backend/src/db/schema/projects.schema.ts new file mode 100644 index 000000000..442bd19cc --- /dev/null +++ b/apps/mukke/apps/backend/src/db/schema/projects.schema.ts @@ -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; diff --git a/apps/mukke/apps/backend/src/db/schema/songs.schema.ts b/apps/mukke/apps/backend/src/db/schema/songs.schema.ts new file mode 100644 index 000000000..556f8238f --- /dev/null +++ b/apps/mukke/apps/backend/src/db/schema/songs.schema.ts @@ -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; diff --git a/apps/mukke/apps/backend/src/db/seed.ts b/apps/mukke/apps/backend/src/db/seed.ts new file mode 100644 index 000000000..17e70faaa --- /dev/null +++ b/apps/mukke/apps/backend/src/db/seed.ts @@ -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); +}); diff --git a/apps/mukke/apps/backend/src/export/export.controller.ts b/apps/mukke/apps/backend/src/export/export.controller.ts new file mode 100644 index 000000000..2442f36b5 --- /dev/null +++ b/apps/mukke/apps/backend/src/export/export.controller.ts @@ -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); + } +} diff --git a/apps/mukke/apps/backend/src/export/export.module.ts b/apps/mukke/apps/backend/src/export/export.module.ts new file mode 100644 index 000000000..f760abd25 --- /dev/null +++ b/apps/mukke/apps/backend/src/export/export.module.ts @@ -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 {} diff --git a/apps/mukke/apps/backend/src/export/export.service.ts b/apps/mukke/apps/backend/src/export/export.service.ts new file mode 100644 index 000000000..57a1dacb2 --- /dev/null +++ b/apps/mukke/apps/backend/src/export/export.service.ts @@ -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); + } +} diff --git a/apps/mukke/apps/backend/src/library/library.controller.ts b/apps/mukke/apps/backend/src/library/library.controller.ts new file mode 100644 index 000000000..3f1223f38 --- /dev/null +++ b/apps/mukke/apps/backend/src/library/library.controller.ts @@ -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 }; + } +} diff --git a/apps/mukke/apps/backend/src/library/library.module.ts b/apps/mukke/apps/backend/src/library/library.module.ts new file mode 100644 index 000000000..7c1edecd9 --- /dev/null +++ b/apps/mukke/apps/backend/src/library/library.module.ts @@ -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 {} diff --git a/apps/mukke/apps/backend/src/library/library.service.spec.ts b/apps/mukke/apps/backend/src/library/library.service.spec.ts new file mode 100644 index 000000000..fc2922aeb --- /dev/null +++ b/apps/mukke/apps/backend/src/library/library.service.spec.ts @@ -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); + }); + + 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(); + }); + }); +}); diff --git a/apps/mukke/apps/backend/src/library/library.service.ts b/apps/mukke/apps/backend/src/library/library.service.ts new file mode 100644 index 000000000..3576aeb7e --- /dev/null +++ b/apps/mukke/apps/backend/src/library/library.service.ts @@ -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)); + } +} diff --git a/apps/mukke/apps/backend/src/lyrics/dto/lyrics.dto.ts b/apps/mukke/apps/backend/src/lyrics/dto/lyrics.dto.ts new file mode 100644 index 000000000..019252484 --- /dev/null +++ b/apps/mukke/apps/backend/src/lyrics/dto/lyrics.dto.ts @@ -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; +} diff --git a/apps/mukke/apps/backend/src/lyrics/lyrics.controller.ts b/apps/mukke/apps/backend/src/lyrics/lyrics.controller.ts new file mode 100644 index 000000000..fd3525c0e --- /dev/null +++ b/apps/mukke/apps/backend/src/lyrics/lyrics.controller.ts @@ -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 }; + } +} diff --git a/apps/mukke/apps/backend/src/lyrics/lyrics.module.ts b/apps/mukke/apps/backend/src/lyrics/lyrics.module.ts new file mode 100644 index 000000000..203ca8185 --- /dev/null +++ b/apps/mukke/apps/backend/src/lyrics/lyrics.module.ts @@ -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 {} diff --git a/apps/mukke/apps/backend/src/lyrics/lyrics.service.ts b/apps/mukke/apps/backend/src/lyrics/lyrics.service.ts new file mode 100644 index 000000000..2125b3081 --- /dev/null +++ b/apps/mukke/apps/backend/src/lyrics/lyrics.service.ts @@ -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 { + 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 { + const [lyricsRecord] = await this.db + .select() + .from(lyrics) + .where(eq(lyrics.projectId, projectId)); + return lyricsRecord || null; + } + + async findById(id: string): Promise { + const [lyricsRecord] = await this.db.select().from(lyrics).where(eq(lyrics.id, id)); + return lyricsRecord || null; + } + + async findByIdOrThrow(id: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/mukke/apps/backend/src/main.ts b/apps/mukke/apps/backend/src/main.ts new file mode 100644 index 000000000..c25c1225b --- /dev/null +++ b/apps/mukke/apps/backend/src/main.ts @@ -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'], +}); diff --git a/apps/mukke/apps/backend/src/marker/dto/marker.dto.ts b/apps/mukke/apps/backend/src/marker/dto/marker.dto.ts new file mode 100644 index 000000000..36f00162d --- /dev/null +++ b/apps/mukke/apps/backend/src/marker/dto/marker.dto.ts @@ -0,0 +1,137 @@ +import { + IsString, + IsNotEmpty, + IsUUID, + IsNumber, + IsOptional, + IsIn, + IsArray, + ValidateNested, + MaxLength, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +const MARKER_TYPES = [ + 'verse', + 'hook', + 'bridge', + 'intro', + 'outro', + 'drop', + 'breakdown', + 'custom', +] as const; + +export class CreateMarkerDto { + @IsUUID() + @IsNotEmpty() + beatId!: string; + + @IsString() + @IsIn(MARKER_TYPES) + type!: string; + + @IsString() + @IsOptional() + @MaxLength(100) + label?: string; + + @IsNumber() + @Min(0) + startTime!: number; + + @IsNumber() + @IsOptional() + @Min(0) + endTime?: number; + + @IsString() + @IsOptional() + @MaxLength(7) + color?: string; +} + +export class UpdateMarkerDto { + @IsString() + @IsIn(MARKER_TYPES) + @IsOptional() + type?: string; + + @IsString() + @IsOptional() + @MaxLength(100) + label?: string; + + @IsNumber() + @IsOptional() + @Min(0) + startTime?: number; + + @IsNumber() + @IsOptional() + @Min(0) + endTime?: number; + + @IsString() + @IsOptional() + @MaxLength(7) + color?: string; + + @IsNumber() + @IsOptional() + sortOrder?: number; +} + +class MarkerItemDto { + @IsString() + @IsIn(MARKER_TYPES) + type!: string; + + @IsString() + @IsOptional() + @MaxLength(100) + label?: string; + + @IsNumber() + @Min(0) + startTime!: number; + + @IsNumber() + @IsOptional() + @Min(0) + endTime?: number; + + @IsString() + @IsOptional() + @MaxLength(7) + color?: string; +} + +export class BulkCreateMarkersDto { + @IsUUID() + @IsNotEmpty() + beatId!: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MarkerItemDto) + markers!: MarkerItemDto[]; +} + +class MarkerUpdateItemDto { + @IsUUID() + @IsNotEmpty() + id!: string; + + @ValidateNested() + @Type(() => UpdateMarkerDto) + data!: UpdateMarkerDto; +} + +export class BulkUpdateMarkersDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MarkerUpdateItemDto) + updates!: MarkerUpdateItemDto[]; +} diff --git a/apps/mukke/apps/backend/src/marker/marker.controller.ts b/apps/mukke/apps/backend/src/marker/marker.controller.ts new file mode 100644 index 000000000..c02d79607 --- /dev/null +++ b/apps/mukke/apps/backend/src/marker/marker.controller.ts @@ -0,0 +1,79 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { MarkerService } from './marker.service'; +import { + CreateMarkerDto, + UpdateMarkerDto, + BulkCreateMarkersDto, + BulkUpdateMarkersDto, +} from './dto/marker.dto'; + +@Controller('markers') +@UseGuards(JwtAuthGuard) +export class MarkerController { + constructor(private readonly markerService: MarkerService) {} + + @Get('beat/:beatId') + async findByBeat( + @CurrentUser() user: CurrentUserData, + @Param('beatId', ParseUUIDPipe) beatId: string + ) { + await this.markerService.verifyBeatOwnership(beatId, user.userId); + const markerList = await this.markerService.findByBeatId(beatId); + return { markers: markerList }; + } + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateMarkerDto) { + await this.markerService.verifyBeatOwnership(dto.beatId, user.userId); + const marker = await this.markerService.create(dto); + return { marker }; + } + + @Post('bulk') + async bulkCreate(@CurrentUser() user: CurrentUserData, @Body() dto: BulkCreateMarkersDto) { + const markerList = await this.markerService.bulkCreate(dto.beatId, user.userId, dto.markers); + return { markers: markerList }; + } + + @Put('bulk') + async bulkUpdate(@CurrentUser() user: CurrentUserData, @Body() dto: BulkUpdateMarkersDto) { + const markerList = await this.markerService.bulkUpdate(user.userId, dto.updates); + return { markers: markerList }; + } + + @Put(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateMarkerDto + ) { + const marker = await this.markerService.update(id, user.userId, dto); + return { marker }; + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + await this.markerService.delete(id, user.userId); + return { success: true }; + } + + @Delete('beat/:beatId') + async deleteAllForBeat( + @CurrentUser() user: CurrentUserData, + @Param('beatId', ParseUUIDPipe) beatId: string + ) { + await this.markerService.deleteAllForBeat(beatId, user.userId); + return { success: true }; + } +} diff --git a/apps/mukke/apps/backend/src/marker/marker.module.ts b/apps/mukke/apps/backend/src/marker/marker.module.ts new file mode 100644 index 000000000..f44725e04 --- /dev/null +++ b/apps/mukke/apps/backend/src/marker/marker.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MarkerController } from './marker.controller'; +import { MarkerService } from './marker.service'; + +@Module({ + controllers: [MarkerController], + providers: [MarkerService], + exports: [MarkerService], +}) +export class MarkerModule {} diff --git a/apps/mukke/apps/backend/src/marker/marker.service.ts b/apps/mukke/apps/backend/src/marker/marker.service.ts new file mode 100644 index 000000000..4ecabfb62 --- /dev/null +++ b/apps/mukke/apps/backend/src/marker/marker.service.ts @@ -0,0 +1,110 @@ +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 { markers, beats, projects } from '../db/schema'; +import type { Marker, NewMarker } from '../db/schema'; + +@Injectable() +export class MarkerService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async verifyBeatOwnership(beatId: string, userId: string): Promise { + const [beat] = await this.db.select().from(beats).where(eq(beats.id, beatId)); + if (!beat) { + throw new NotFoundException('Beat not found'); + } + const [project] = await this.db + .select() + .from(projects) + .where(and(eq(projects.id, beat.projectId), eq(projects.userId, userId))); + if (!project) { + throw new NotFoundException('Project not found'); + } + } + + async findByBeatId(beatId: string): Promise { + return this.db + .select() + .from(markers) + .where(eq(markers.beatId, beatId)) + .orderBy(asc(markers.startTime)); + } + + async findById(id: string): Promise { + const [marker] = await this.db.select().from(markers).where(eq(markers.id, id)); + return marker || null; + } + + async findByIdOrThrow(id: string): Promise { + const marker = await this.findById(id); + if (!marker) { + throw new NotFoundException('Marker not found'); + } + return marker; + } + + async create(data: NewMarker): Promise { + const [marker] = await this.db.insert(markers).values(data).returning(); + return marker; + } + + async update( + id: string, + userId: string, + data: Partial> + ): Promise { + const marker = await this.findByIdOrThrow(id); + await this.verifyBeatOwnership(marker.beatId, userId); + + const [updatedMarker] = await this.db + .update(markers) + .set(data) + .where(eq(markers.id, id)) + .returning(); + return updatedMarker; + } + + async delete(id: string, userId: string): Promise { + const marker = await this.findByIdOrThrow(id); + await this.verifyBeatOwnership(marker.beatId, userId); + await this.db.delete(markers).where(eq(markers.id, id)); + } + + async deleteAllForBeat(beatId: string, userId: string): Promise { + await this.verifyBeatOwnership(beatId, userId); + await this.db.delete(markers).where(eq(markers.beatId, beatId)); + } + + async bulkCreate( + beatId: string, + userId: string, + items: Omit[] + ): Promise { + await this.verifyBeatOwnership(beatId, userId); + + if (items.length === 0) return []; + + const values = items.map((item) => ({ + ...item, + beatId, + })); + + return this.db.insert(markers).values(values).returning(); + } + + async bulkUpdate( + userId: string, + updates: Array<{ + id: string; + data: Partial>; + }> + ): Promise { + const results: Marker[] = []; + for (const update of updates) { + const marker = await this.update(update.id, userId, update.data); + results.push(marker); + } + return results; + } +} diff --git a/apps/mukke/apps/backend/src/playlist/dto/playlist.dto.ts b/apps/mukke/apps/backend/src/playlist/dto/playlist.dto.ts new file mode 100644 index 000000000..17ab04b34 --- /dev/null +++ b/apps/mukke/apps/backend/src/playlist/dto/playlist.dto.ts @@ -0,0 +1,35 @@ +import { IsString, IsNotEmpty, IsOptional, IsUUID, MaxLength } from 'class-validator'; + +export class CreatePlaylistDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + name!: string; + + @IsString() + @IsOptional() + description?: string; +} + +export class UpdatePlaylistDto { + @IsString() + @IsOptional() + @MaxLength(255) + name?: string; + + @IsString() + @IsOptional() + description?: string; +} + +export class AddSongDto { + @IsUUID() + @IsNotEmpty() + songId!: string; +} + +export class ReorderSongsDto { + @IsUUID('4', { each: true }) + @IsNotEmpty({ each: true }) + songIds!: string[]; +} diff --git a/apps/mukke/apps/backend/src/playlist/playlist.controller.ts b/apps/mukke/apps/backend/src/playlist/playlist.controller.ts new file mode 100644 index 000000000..b6d8ae9cc --- /dev/null +++ b/apps/mukke/apps/backend/src/playlist/playlist.controller.ts @@ -0,0 +1,89 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { PlaylistService } from './playlist.service'; +import { + CreatePlaylistDto, + UpdatePlaylistDto, + AddSongDto, + ReorderSongsDto, +} from './dto/playlist.dto'; + +@Controller('playlists') +@UseGuards(JwtAuthGuard) +export class PlaylistController { + constructor(private readonly playlistService: PlaylistService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserData) { + const playlistList = await this.playlistService.findByUserId(user.userId); + return { playlists: playlistList }; + } + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreatePlaylistDto) { + const playlist = await this.playlistService.create(user.userId, dto); + return { playlist }; + } + + @Get(':id') + async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + const playlist = await this.playlistService.getPlaylistWithSongs(id, user.userId); + return { playlist }; + } + + @Put(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdatePlaylistDto + ) { + const playlist = await this.playlistService.update(id, user.userId, dto); + return { playlist }; + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + await this.playlistService.delete(id, user.userId); + return { success: true }; + } + + @Post(':id/songs') + async addSong( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AddSongDto + ) { + await this.playlistService.addSong(id, dto.songId, user.userId); + return { success: true }; + } + + @Delete(':id/songs/:songId') + async removeSong( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Param('songId', ParseUUIDPipe) songId: string + ) { + await this.playlistService.removeSong(id, songId, user.userId); + return { success: true }; + } + + @Put(':id/songs/reorder') + async reorderSongs( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ReorderSongsDto + ) { + await this.playlistService.reorderSongs(id, user.userId, dto.songIds); + return { success: true }; + } +} diff --git a/apps/mukke/apps/backend/src/playlist/playlist.module.ts b/apps/mukke/apps/backend/src/playlist/playlist.module.ts new file mode 100644 index 000000000..17215f991 --- /dev/null +++ b/apps/mukke/apps/backend/src/playlist/playlist.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PlaylistController } from './playlist.controller'; +import { PlaylistService } from './playlist.service'; + +@Module({ + controllers: [PlaylistController], + providers: [PlaylistService], + exports: [PlaylistService], +}) +export class PlaylistModule {} diff --git a/apps/mukke/apps/backend/src/playlist/playlist.service.spec.ts b/apps/mukke/apps/backend/src/playlist/playlist.service.spec.ts new file mode 100644 index 000000000..2d796de12 --- /dev/null +++ b/apps/mukke/apps/backend/src/playlist/playlist.service.spec.ts @@ -0,0 +1,199 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { PlaylistService } from './playlist.service'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { + createMockPlaylist, + createMockSong, + TEST_USER_ID, +} from '../__tests__/utils/mock-factories'; + +describe('PlaylistService', () => { + let service: PlaylistService; + let mockDb: any; + + beforeEach(async () => { + mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PlaylistService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + ], + }).compile(); + + service = module.get(PlaylistService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findByUserId', () => { + it('should return all playlists for a user', async () => { + const playlists = [ + createMockPlaylist({ name: 'Playlist 1' }), + createMockPlaylist({ name: 'Playlist 2' }), + ]; + mockDb.orderBy.mockResolvedValueOnce(playlists); + + const result = await service.findByUserId(TEST_USER_ID); + + expect(result).toEqual(playlists); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + }); + + it('should return empty array when no playlists', async () => { + mockDb.orderBy.mockResolvedValueOnce([]); + + const result = await service.findByUserId(TEST_USER_ID); + + expect(result).toEqual([]); + }); + }); + + describe('findById', () => { + it('should return playlist when found', async () => { + const playlist = createMockPlaylist(); + mockDb.where.mockResolvedValueOnce([playlist]); + + const result = await service.findById(playlist.id, TEST_USER_ID); + + expect(result).toEqual(playlist); + }); + + it('should throw NotFoundException when not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.findById('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('create', () => { + it('should create a new playlist', async () => { + const newPlaylist = createMockPlaylist({ name: 'New Playlist' }); + mockDb.returning.mockResolvedValueOnce([newPlaylist]); + + const result = await service.create(TEST_USER_ID, { + name: 'New Playlist', + description: 'A new playlist', + }); + + expect(result).toEqual(newPlaylist); + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('should update playlist name', async () => { + const playlist = createMockPlaylist(); + const updatedPlaylist = { ...playlist, name: 'Updated Name' }; + + // Mock findById + mockDb.where.mockResolvedValueOnce([playlist]); + // Mock update returning + mockDb.returning.mockResolvedValueOnce([updatedPlaylist]); + + const result = await service.update(playlist.id, TEST_USER_ID, { + name: 'Updated Name', + }); + + expect(result.name).toBe('Updated Name'); + }); + + it('should throw NotFoundException for non-existent playlist', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect( + service.update('non-existent-id', TEST_USER_ID, { name: 'New Name' }) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('delete', () => { + it('should delete playlist', async () => { + const playlist = createMockPlaylist(); + mockDb.where.mockResolvedValueOnce([playlist]); + + await service.delete(playlist.id, TEST_USER_ID); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it('should throw NotFoundException for non-existent playlist', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.delete('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('addSong', () => { + it('should add a song to a playlist with correct sort order', async () => { + const playlist = createMockPlaylist(); + const song = createMockSong(); + + // Mock findById + mockDb.where.mockResolvedValueOnce([playlist]); + // Mock select maxOrder + mockDb.where.mockResolvedValueOnce([{ maxOrder: 2 }]); + // Mock insert (no return needed, returns void) + mockDb.values.mockReturnThis(); + // Mock update for updatedAt + mockDb.where.mockResolvedValueOnce(undefined); + + await service.addSong(playlist.id, song.id, TEST_USER_ID); + + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.update).toHaveBeenCalled(); + }); + }); + + describe('removeSong', () => { + it('should remove a song from a playlist', async () => { + const playlist = createMockPlaylist(); + const song = createMockSong(); + + // Mock findById + mockDb.where.mockResolvedValueOnce([playlist]); + // Mock delete + mockDb.where.mockResolvedValueOnce(undefined); + // Mock update for updatedAt + mockDb.where.mockResolvedValueOnce(undefined); + + await service.removeSong(playlist.id, song.id, TEST_USER_ID); + + expect(mockDb.delete).toHaveBeenCalled(); + expect(mockDb.update).toHaveBeenCalled(); + }); + + it('should throw NotFoundException for non-existent playlist', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.removeSong('non-existent-id', 'song-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + }); +}); diff --git a/apps/mukke/apps/backend/src/playlist/playlist.service.ts b/apps/mukke/apps/backend/src/playlist/playlist.service.ts new file mode 100644 index 000000000..2e5ca9deb --- /dev/null +++ b/apps/mukke/apps/backend/src/playlist/playlist.service.ts @@ -0,0 +1,131 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { eq, and, desc, asc, max, sql } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { playlists, playlistSongs, songs } from '../db/schema'; +import type { Playlist, NewPlaylist } from '../db/schema'; + +@Injectable() +export class PlaylistService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async findByUserId(userId: string): Promise { + return this.db + .select() + .from(playlists) + .where(eq(playlists.userId, userId)) + .orderBy(desc(playlists.updatedAt)); + } + + async findById(id: string, userId: string): Promise { + const [playlist] = await this.db + .select() + .from(playlists) + .where(and(eq(playlists.id, id), eq(playlists.userId, userId))); + if (!playlist) { + throw new NotFoundException('Playlist not found'); + } + return playlist; + } + + async create(userId: string, data: { name: string; description?: string }): Promise { + const [playlist] = await this.db + .insert(playlists) + .values({ + userId, + name: data.name, + description: data.description, + }) + .returning(); + return playlist; + } + + async update( + id: string, + userId: string, + data: { name?: string; description?: string } + ): Promise { + await this.findById(id, userId); + const [playlist] = await this.db + .update(playlists) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(and(eq(playlists.id, id), eq(playlists.userId, userId))) + .returning(); + return playlist; + } + + async delete(id: string, userId: string): Promise { + await this.findById(id, userId); + await this.db.delete(playlists).where(and(eq(playlists.id, id), eq(playlists.userId, userId))); + } + + async getPlaylistWithSongs(id: string, userId: string) { + const playlist = await this.findById(id, userId); + const playlistSongRows = await this.db + .select({ + playlistSong: playlistSongs, + song: songs, + }) + .from(playlistSongs) + .innerJoin(songs, eq(playlistSongs.songId, songs.id)) + .where(eq(playlistSongs.playlistId, id)) + .orderBy(asc(playlistSongs.sortOrder)); + + return { + ...playlist, + songs: playlistSongRows.map((row) => ({ + ...row.song, + sortOrder: row.playlistSong.sortOrder, + addedAt: row.playlistSong.addedAt, + })), + }; + } + + async addSong(playlistId: string, songId: string, userId: string): Promise { + await this.findById(playlistId, userId); + + const [result] = await this.db + .select({ maxOrder: max(playlistSongs.sortOrder) }) + .from(playlistSongs) + .where(eq(playlistSongs.playlistId, playlistId)); + + const nextOrder = (result?.maxOrder ?? -1) + 1; + + await this.db.insert(playlistSongs).values({ + playlistId, + songId, + sortOrder: nextOrder, + }); + + await this.db + .update(playlists) + .set({ updatedAt: new Date() }) + .where(eq(playlists.id, playlistId)); + } + + async removeSong(playlistId: string, songId: string, userId: string): Promise { + await this.findById(playlistId, userId); + await this.db + .delete(playlistSongs) + .where(and(eq(playlistSongs.playlistId, playlistId), eq(playlistSongs.songId, songId))); + + await this.db + .update(playlists) + .set({ updatedAt: new Date() }) + .where(eq(playlists.id, playlistId)); + } + + async reorderSongs(playlistId: string, userId: string, songIds: string[]): Promise { + await this.findById(playlistId, userId); + + for (let i = 0; i < songIds.length; i++) { + await this.db + .update(playlistSongs) + .set({ sortOrder: i }) + .where(and(eq(playlistSongs.playlistId, playlistId), eq(playlistSongs.songId, songIds[i]))); + } + } +} diff --git a/apps/mukke/apps/backend/src/project/dto/project.dto.ts b/apps/mukke/apps/backend/src/project/dto/project.dto.ts new file mode 100644 index 000000000..9bd0098fa --- /dev/null +++ b/apps/mukke/apps/backend/src/project/dto/project.dto.ts @@ -0,0 +1,23 @@ +import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator'; + +export class CreateProjectDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + title!: string; + + @IsString() + @IsOptional() + description?: string; +} + +export class UpdateProjectDto { + @IsString() + @IsOptional() + @MaxLength(255) + title?: string; + + @IsString() + @IsOptional() + description?: string; +} diff --git a/apps/mukke/apps/backend/src/project/project.controller.ts b/apps/mukke/apps/backend/src/project/project.controller.ts new file mode 100644 index 000000000..43bf01051 --- /dev/null +++ b/apps/mukke/apps/backend/src/project/project.controller.ts @@ -0,0 +1,67 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { ProjectService } from './project.service'; +import { CreateProjectDto, UpdateProjectDto } from './dto/project.dto'; + +@Controller('projects') +@UseGuards(JwtAuthGuard) +export class ProjectController { + constructor(private readonly projectService: ProjectService) {} + + @Get() + async findAll(@CurrentUser() user: CurrentUserData) { + const projectsList = await this.projectService.findByUserId(user.userId); + return { projects: projectsList }; + } + + @Get(':id') + async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + const project = await this.projectService.getProjectWithRelations(id, user.userId); + return { project }; + } + + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateProjectDto) { + const project = await this.projectService.create({ + userId: user.userId, + title: dto.title, + description: dto.description, + }); + return { project }; + } + + @Put(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateProjectDto + ) { + const project = await this.projectService.update(id, user.userId, dto); + return { project }; + } + + @Post('from-song/:songId') + async createFromSong( + @CurrentUser() user: CurrentUserData, + @Param('songId', ParseUUIDPipe) songId: string + ) { + const project = await this.projectService.createFromSong(songId, user.userId); + return { project }; + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + await this.projectService.delete(id, user.userId); + return { success: true }; + } +} diff --git a/apps/mukke/apps/backend/src/project/project.module.ts b/apps/mukke/apps/backend/src/project/project.module.ts new file mode 100644 index 000000000..19ddb7bbb --- /dev/null +++ b/apps/mukke/apps/backend/src/project/project.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ProjectController } from './project.controller'; +import { ProjectService } from './project.service'; + +@Module({ + controllers: [ProjectController], + providers: [ProjectService], + exports: [ProjectService], +}) +export class ProjectModule {} diff --git a/apps/mukke/apps/backend/src/project/project.service.ts b/apps/mukke/apps/backend/src/project/project.service.ts new file mode 100644 index 000000000..a0284dfc9 --- /dev/null +++ b/apps/mukke/apps/backend/src/project/project.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { eq, and, desc } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { projects, beats, lyrics, songs } from '../db/schema'; +import type { Project, NewProject } from '../db/schema'; + +@Injectable() +export class ProjectService { + constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} + + async findByUserId(userId: string): Promise { + return this.db + .select() + .from(projects) + .where(eq(projects.userId, userId)) + .orderBy(desc(projects.updatedAt)); + } + + async findById(id: string, userId: string): Promise { + const [project] = await this.db + .select() + .from(projects) + .where(and(eq(projects.id, id), eq(projects.userId, userId))); + return project || null; + } + + async findByIdOrThrow(id: string, userId: string): Promise { + const project = await this.findById(id, userId); + if (!project) { + throw new NotFoundException('Project not found'); + } + return project; + } + + async create(data: NewProject): Promise { + const [project] = await this.db.insert(projects).values(data).returning(); + return project; + } + + async update( + id: string, + userId: string, + data: Partial> + ): Promise { + await this.findByIdOrThrow(id, userId); + const [project] = await this.db + .update(projects) + .set({ ...data, updatedAt: new Date() }) + .where(and(eq(projects.id, id), eq(projects.userId, userId))) + .returning(); + return project; + } + + async delete(id: string, userId: string): Promise { + await this.findByIdOrThrow(id, userId); + await this.db.delete(projects).where(and(eq(projects.id, id), eq(projects.userId, userId))); + } + + async createFromSong(songId: string, userId: string): Promise { + const [song] = await this.db + .select() + .from(songs) + .where(and(eq(songs.id, songId), eq(songs.userId, userId))); + if (!song) { + throw new NotFoundException('Song not found'); + } + + const title = song.artist ? `${song.title} - ${song.artist}` : song.title; + const [project] = await this.db.insert(projects).values({ userId, title, songId }).returning(); + + // Create a beat record linked to the song's storage + await this.db.insert(beats).values({ + projectId: project.id, + storagePath: song.storagePath, + filename: `${song.title}.mp3`, + duration: song.duration, + bpm: song.bpm, + }); + + return project; + } + + async getProjectWithRelations(id: string, userId: string) { + const project = await this.findByIdOrThrow(id, userId); + + const [projectBeat] = await this.db.select().from(beats).where(eq(beats.projectId, id)); + + const [projectLyrics] = await this.db.select().from(lyrics).where(eq(lyrics.projectId, id)); + + return { + ...project, + beat: projectBeat || null, + lyrics: projectLyrics || null, + }; + } +} diff --git a/apps/mukke/apps/backend/src/song/dto/song.dto.ts b/apps/mukke/apps/backend/src/song/dto/song.dto.ts new file mode 100644 index 000000000..133fab02b --- /dev/null +++ b/apps/mukke/apps/backend/src/song/dto/song.dto.ts @@ -0,0 +1,79 @@ +import { IsString, IsNotEmpty, IsOptional, IsNumber, IsInt } from 'class-validator'; + +export class CreateSongDto { + @IsString() + @IsNotEmpty() + title!: string; + + @IsString() + @IsOptional() + artist?: string; + + @IsString() + @IsOptional() + album?: string; + + @IsString() + @IsOptional() + genre?: string; + + @IsInt() + @IsOptional() + trackNumber?: number; + + @IsInt() + @IsOptional() + year?: number; + + @IsNumber() + @IsOptional() + bpm?: number; +} + +export class UpdateSongDto { + @IsString() + @IsOptional() + title?: string; + + @IsString() + @IsOptional() + artist?: string; + + @IsString() + @IsOptional() + album?: string; + + @IsString() + @IsOptional() + albumArtist?: string; + + @IsString() + @IsOptional() + genre?: string; + + @IsInt() + @IsOptional() + trackNumber?: number; + + @IsInt() + @IsOptional() + year?: number; + + @IsNumber() + @IsOptional() + duration?: number; + + @IsNumber() + @IsOptional() + bpm?: number; + + @IsInt() + @IsOptional() + fileSize?: number; +} + +export class SongUploadDto { + @IsString() + @IsNotEmpty() + filename!: string; +} diff --git a/apps/mukke/apps/backend/src/song/song.controller.ts b/apps/mukke/apps/backend/src/song/song.controller.ts new file mode 100644 index 000000000..db24f66e1 --- /dev/null +++ b/apps/mukke/apps/backend/src/song/song.controller.ts @@ -0,0 +1,102 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; +import { SongService } from './song.service'; +import { SongUploadDto, UpdateSongDto } from './dto/song.dto'; + +@Controller('songs') +@UseGuards(JwtAuthGuard) +export class SongController { + constructor(private readonly songService: SongService) {} + + @Post('upload') + async createUploadUrl(@CurrentUser() user: CurrentUserData, @Body() dto: SongUploadDto) { + const result = await this.songService.createUploadUrl(user.userId, dto.filename); + return { song: result.song, uploadUrl: result.uploadUrl }; + } + + @Post(':id/cover-upload') + async createCoverUploadUrl( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() body: { filename: string } + ) { + const result = await this.songService.createCoverUploadUrl(id, user.userId, body.filename); + return { uploadUrl: result.uploadUrl }; + } + + @Get() + async findAll( + @CurrentUser() user: CurrentUserData, + @Query('sort') sort?: string, + @Query('direction') direction?: string + ) { + const songList = await this.songService.findByUserId(user.userId, sort, direction); + return { songs: songList }; + } + + @Get('search') + async search(@CurrentUser() user: CurrentUserData, @Query('q') query: string) { + const songList = await this.songService.search(user.userId, query || ''); + return { songs: songList }; + } + + @Get(':id') + async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + const song = await this.songService.findByIdOrThrow(id, user.userId); + return { song }; + } + + @Get(':id/download-url') + async getDownloadUrl( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string + ) { + const url = await this.songService.getDownloadUrl(id, user.userId); + return { url }; + } + + @Put(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateSongDto + ) { + const song = await this.songService.updateMetadata(id, user.userId, dto); + return { song }; + } + + @Put(':id/favorite') + async toggleFavorite( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string + ) { + const song = await this.songService.toggleFavorite(id, user.userId); + return { song }; + } + + @Put(':id/play') + async incrementPlayCount( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string + ) { + const song = await this.songService.incrementPlayCount(id, user.userId); + return { song }; + } + + @Delete(':id') + async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + await this.songService.delete(id, user.userId); + return { success: true }; + } +} diff --git a/apps/mukke/apps/backend/src/song/song.module.ts b/apps/mukke/apps/backend/src/song/song.module.ts new file mode 100644 index 000000000..4619380c4 --- /dev/null +++ b/apps/mukke/apps/backend/src/song/song.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SongController } from './song.controller'; +import { SongService } from './song.service'; + +@Module({ + controllers: [SongController], + providers: [SongService], + exports: [SongService], +}) +export class SongModule {} diff --git a/apps/mukke/apps/backend/src/song/song.service.spec.ts b/apps/mukke/apps/backend/src/song/song.service.spec.ts new file mode 100644 index 000000000..6bd121a6d --- /dev/null +++ b/apps/mukke/apps/backend/src/song/song.service.spec.ts @@ -0,0 +1,331 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { SongService } from './song.service'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { createMockSong, TEST_USER_ID } from '../__tests__/utils/mock-factories'; + +// Mock the storage module +jest.mock('@manacore/shared-storage', () => ({ + createMukkeStorage: jest.fn(() => ({ + getUploadUrl: jest.fn().mockResolvedValue('https://s3.example.com/upload'), + getDownloadUrl: jest.fn().mockResolvedValue('https://s3.example.com/download'), + delete: jest.fn().mockResolvedValue(undefined), + })), + generateUserFileKey: jest.fn((userId: string, filename: string) => `users/${userId}/${filename}`), + getContentType: jest.fn((filename: string) => { + if (filename.endsWith('.mp3')) return 'audio/mpeg'; + if (filename.endsWith('.wav')) return 'audio/wav'; + if (filename.endsWith('.txt')) return 'text/plain'; + return 'application/octet-stream'; + }), +})); + +describe('SongService', () => { + let service: SongService; + let mockDb: any; + + beforeEach(async () => { + mockDb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + insert: jest.fn().mockReturnThis(), + values: jest.fn().mockReturnThis(), + returning: jest.fn(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + delete: jest.fn().mockReturnThis(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SongService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + ], + }).compile(); + + service = module.get(SongService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createUploadUrl', () => { + it('should create song record and return upload URL', async () => { + const song = createMockSong({ title: 'test-song' }); + mockDb.returning.mockResolvedValueOnce([song]); + + const result = await service.createUploadUrl(TEST_USER_ID, 'test-song.mp3'); + + expect(result.song).toEqual(song); + expect(result.uploadUrl).toBe('https://s3.example.com/upload'); + expect(mockDb.insert).toHaveBeenCalled(); + expect(mockDb.values).toHaveBeenCalled(); + expect(mockDb.returning).toHaveBeenCalled(); + }); + + it('should reject non-audio files', async () => { + await expect(service.createUploadUrl(TEST_USER_ID, 'test.txt')).rejects.toThrow( + BadRequestException + ); + }); + }); + + describe('findByUserId', () => { + it('should return all songs for a user', async () => { + const songs = [createMockSong(), createMockSong({ title: 'Song 2' })]; + mockDb.orderBy.mockResolvedValueOnce(songs); + + const result = await service.findByUserId(TEST_USER_ID); + + expect(result).toEqual(songs); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + }); + + it('should return empty array when no songs', async () => { + mockDb.orderBy.mockResolvedValueOnce([]); + + const result = await service.findByUserId(TEST_USER_ID); + + expect(result).toEqual([]); + }); + }); + + describe('findById', () => { + it('should return song when found', async () => { + const song = createMockSong(); + mockDb.where.mockResolvedValueOnce([song]); + + const result = await service.findById(song.id, TEST_USER_ID); + + expect(result).toEqual(song); + }); + + it('should return null when not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + const result = await service.findById('non-existent-id', TEST_USER_ID); + + expect(result).toBeNull(); + }); + }); + + describe('findByIdOrThrow', () => { + it('should return song when found', async () => { + const song = createMockSong(); + mockDb.where.mockResolvedValueOnce([song]); + + const result = await service.findByIdOrThrow(song.id, TEST_USER_ID); + + expect(result).toEqual(song); + }); + + it('should throw NotFoundException when not found', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.findByIdOrThrow('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('toggleFavorite', () => { + it('should toggle favorite from false to true', async () => { + const song = createMockSong({ favorite: false }); + const updatedSong = createMockSong({ ...song, favorite: true }); + + // First call: findByIdOrThrow -> findById + mockDb.where.mockResolvedValueOnce([song]); + // Second call: update returning + mockDb.returning.mockResolvedValueOnce([updatedSong]); + + const result = await service.toggleFavorite(song.id, TEST_USER_ID); + + expect(result).toEqual(updatedSong); + expect(result.favorite).toBe(true); + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalled(); + }); + + it('should throw NotFoundException for non-existent song', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.toggleFavorite('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('incrementPlayCount', () => { + it('should increment play count and set lastPlayedAt', async () => { + const song = createMockSong({ playCount: 5 }); + const updatedSong = createMockSong({ + ...song, + playCount: 6, + lastPlayedAt: new Date(), + }); + + // First call: findByIdOrThrow -> findById + mockDb.where.mockResolvedValueOnce([song]); + // Second call: update returning + mockDb.returning.mockResolvedValueOnce([updatedSong]); + + const result = await service.incrementPlayCount(song.id, TEST_USER_ID); + + expect(result).toEqual(updatedSong); + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalled(); + }); + + it('should throw NotFoundException for non-existent song', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.incrementPlayCount('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('search', () => { + it('should return matching songs', async () => { + const songs = [createMockSong({ title: 'Bohemian Rhapsody' })]; + mockDb.limit.mockResolvedValueOnce(songs); + + const result = await service.search(TEST_USER_ID, 'Bohemian'); + + expect(result).toEqual(songs); + expect(mockDb.select).toHaveBeenCalled(); + expect(mockDb.from).toHaveBeenCalled(); + expect(mockDb.where).toHaveBeenCalled(); + expect(mockDb.limit).toHaveBeenCalled(); + }); + + it('should return empty array for no matches', async () => { + mockDb.limit.mockResolvedValueOnce([]); + + const result = await service.search(TEST_USER_ID, 'nonexistent'); + + expect(result).toEqual([]); + }); + }); + + describe('updateMetadata', () => { + it('should update song metadata', async () => { + const song = createMockSong(); + const updatedSong = createMockSong({ + ...song, + title: 'Updated Title', + artist: 'Updated Artist', + }); + + // First call: findByIdOrThrow -> findById + mockDb.where.mockResolvedValueOnce([song]); + // Second call: update returning + mockDb.returning.mockResolvedValueOnce([updatedSong]); + + const result = await service.updateMetadata(song.id, TEST_USER_ID, { + title: 'Updated Title', + artist: 'Updated Artist', + }); + + expect(result).toEqual(updatedSong); + expect(result.title).toBe('Updated Title'); + expect(result.artist).toBe('Updated Artist'); + expect(mockDb.update).toHaveBeenCalled(); + expect(mockDb.set).toHaveBeenCalled(); + }); + + it('should throw NotFoundException for non-existent song', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect( + service.updateMetadata('non-existent-id', TEST_USER_ID, { title: 'New Title' }) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('delete', () => { + it('should delete song from storage and database', async () => { + const song = createMockSong(); + + // First call: findByIdOrThrow -> findById + mockDb.where.mockResolvedValueOnce([song]); + // Second call: db.delete().where() + mockDb.where.mockResolvedValueOnce(undefined); + + await service.delete(song.id, TEST_USER_ID); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it('should still delete from DB if storage delete fails', async () => { + const { createMukkeStorage } = require('@manacore/shared-storage'); + const mockStorage = { + getUploadUrl: jest.fn().mockResolvedValue('https://s3.example.com/upload'), + getDownloadUrl: jest.fn().mockResolvedValue('https://s3.example.com/download'), + delete: jest.fn().mockRejectedValue(new Error('Storage error')), + }; + createMukkeStorage.mockReturnValue(mockStorage); + + // Re-create the service to pick up the new mock + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SongService, + { + provide: DATABASE_CONNECTION, + useValue: mockDb, + }, + ], + }).compile(); + const serviceWithFailingStorage = module.get(SongService); + + const song = createMockSong(); + + // First call: findByIdOrThrow -> findById + mockDb.where.mockResolvedValueOnce([song]); + // Second call: db.delete().where() + mockDb.where.mockResolvedValueOnce(undefined); + + await serviceWithFailingStorage.delete(song.id, TEST_USER_ID); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it('should throw NotFoundException for non-existent song', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.delete('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('getDownloadUrl', () => { + it('should return presigned download URL', async () => { + const song = createMockSong(); + + // findByIdOrThrow -> findById + mockDb.where.mockResolvedValueOnce([song]); + + const result = await service.getDownloadUrl(song.id, TEST_USER_ID); + + expect(result).toBe('https://s3.example.com/download'); + }); + + it('should throw NotFoundException for non-existent song', async () => { + mockDb.where.mockResolvedValueOnce([]); + + await expect(service.getDownloadUrl('non-existent-id', TEST_USER_ID)).rejects.toThrow( + NotFoundException + ); + }); + }); +}); diff --git a/apps/mukke/apps/backend/src/song/song.service.ts b/apps/mukke/apps/backend/src/song/song.service.ts new file mode 100644 index 000000000..d38ddefeb --- /dev/null +++ b/apps/mukke/apps/backend/src/song/song.service.ts @@ -0,0 +1,217 @@ +import { Injectable, Inject, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; +import { eq, and, desc, asc, ilike, or, sql } from 'drizzle-orm'; +import { DATABASE_CONNECTION } from '../db/database.module'; +import { Database } from '../db/connection'; +import { songs } from '../db/schema'; +import type { Song } from '../db/schema'; +import { + createMukkeStorage, + generateUserFileKey, + getContentType, + type StorageClient, +} from '@manacore/shared-storage'; + +@Injectable() +export class SongService { + private readonly logger = new Logger(SongService.name); + private storage: StorageClient; + + constructor(@Inject(DATABASE_CONNECTION) private db: Database) { + this.storage = createMukkeStorage(); + } + + async createUploadUrl( + userId: string, + filename: string + ): Promise<{ song: Song; uploadUrl: string }> { + 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.'); + } + + const [song] = await this.db + .insert(songs) + .values({ + userId, + title: filename.replace(/\.[^/.]+$/, ''), + storagePath: key, + }) + .returning(); + + const uploadUrl = await this.storage.getUploadUrl(key, { + expiresIn: 3600, + }); + + return { song, uploadUrl }; + } + + async createCoverUploadUrl( + songId: string, + userId: string, + filename: string + ): Promise<{ uploadUrl: string }> { + const song = await this.findByIdOrThrow(songId, userId); + + const key = generateUserFileKey(userId, `covers/${filename}`); + + await this.db + .update(songs) + .set({ coverArtPath: key, updatedAt: new Date() }) + .where(eq(songs.id, songId)); + + const uploadUrl = await this.storage.getUploadUrl(key, { + expiresIn: 3600, + }); + + return { uploadUrl }; + } + + async updateMetadata( + id: string, + userId: string, + data: { + title?: string; + artist?: string; + album?: string; + albumArtist?: string; + genre?: string; + trackNumber?: number; + year?: number; + duration?: number; + bpm?: number; + fileSize?: number; + } + ): Promise { + await this.findByIdOrThrow(id, userId); + + const [updatedSong] = await this.db + .update(songs) + .set({ ...data, updatedAt: new Date() }) + .where(and(eq(songs.id, id), eq(songs.userId, userId))) + .returning(); + + return updatedSong; + } + + async findByUserId( + userId: string, + sortField: string = 'addedAt', + sortDirection: string = 'desc' + ): Promise { + const sortColumn = this.getSortColumn(sortField); + const orderFn = sortDirection === 'asc' ? asc : desc; + + return this.db + .select() + .from(songs) + .where(eq(songs.userId, userId)) + .orderBy(orderFn(sortColumn)); + } + + async findById(id: string, userId: string): Promise { + const [song] = await this.db + .select() + .from(songs) + .where(and(eq(songs.id, id), eq(songs.userId, userId))); + return song || null; + } + + async findByIdOrThrow(id: string, userId: string): Promise { + const song = await this.findById(id, userId); + if (!song) { + throw new NotFoundException('Song not found'); + } + return song; + } + + async toggleFavorite(id: string, userId: string): Promise { + const song = await this.findByIdOrThrow(id, userId); + + const [updatedSong] = await this.db + .update(songs) + .set({ favorite: !song.favorite, updatedAt: new Date() }) + .where(and(eq(songs.id, id), eq(songs.userId, userId))) + .returning(); + + return updatedSong; + } + + async incrementPlayCount(id: string, userId: string): Promise { + await this.findByIdOrThrow(id, userId); + + const [updatedSong] = await this.db + .update(songs) + .set({ + playCount: sql`${songs.playCount} + 1`, + lastPlayedAt: new Date(), + updatedAt: new Date(), + }) + .where(and(eq(songs.id, id), eq(songs.userId, userId))) + .returning(); + + return updatedSong; + } + + async search(userId: string, query: string): Promise { + const pattern = `%${query}%`; + + return this.db + .select() + .from(songs) + .where( + and( + eq(songs.userId, userId), + or(ilike(songs.title, pattern), ilike(songs.artist, pattern), ilike(songs.album, pattern)) + ) + ) + .limit(50); + } + + async delete(id: string, userId: string): Promise { + const song = await this.findByIdOrThrow(id, userId); + + // Delete audio from storage + try { + await this.storage.delete(song.storagePath); + } catch { + // Ignore storage errors, continue with DB deletion + } + + // Delete cover art from storage if exists + if (song.coverArtPath) { + try { + await this.storage.delete(song.coverArtPath); + } catch { + // Ignore storage errors + } + } + + await this.db.delete(songs).where(and(eq(songs.id, id), eq(songs.userId, userId))); + } + + async getDownloadUrl(id: string, userId: string): Promise { + const song = await this.findByIdOrThrow(id, userId); + return this.storage.getDownloadUrl(song.storagePath, { expiresIn: 3600 }); + } + + private getSortColumn(field: string) { + switch (field) { + case 'title': + return songs.title; + case 'artist': + return songs.artist; + case 'album': + return songs.album; + case 'duration': + return songs.duration; + case 'playCount': + return songs.playCount; + case 'lastPlayedAt': + return songs.lastPlayedAt; + default: + return songs.addedAt; + } + } +} diff --git a/apps/mukke/apps/backend/src/stt/stt.module.ts b/apps/mukke/apps/backend/src/stt/stt.module.ts new file mode 100644 index 000000000..acc7f6132 --- /dev/null +++ b/apps/mukke/apps/backend/src/stt/stt.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SttService } from './stt.service'; + +@Module({ + providers: [SttService], + exports: [SttService], +}) +export class SttModule {} diff --git a/apps/mukke/apps/backend/src/stt/stt.service.ts b/apps/mukke/apps/backend/src/stt/stt.service.ts new file mode 100644 index 000000000..2500e466f --- /dev/null +++ b/apps/mukke/apps/backend/src/stt/stt.service.ts @@ -0,0 +1,89 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +export interface TranscriptionResult { + text: string; + language: string | null; + model: string; + latencyMs: number | null; + durationSeconds: number | null; +} + +@Injectable() +export class SttService { + private readonly logger = new Logger(SttService.name); + private readonly sttUrl: string; + private readonly apiKey: string | undefined; + + constructor(private configService: ConfigService) { + this.sttUrl = this.configService.get('MANA_STT_URL') || 'http://localhost:3020'; + this.apiKey = this.configService.get('MANA_STT_API_KEY'); + } + + /** + * Check if mana-stt service is available + */ + async isAvailable(): Promise { + try { + const response = await fetch(`${this.sttUrl}/health`, { + method: 'GET', + signal: AbortSignal.timeout(5000), + }); + return response.ok; + } catch (error) { + this.logger.warn(`STT service not available: ${error}`); + return false; + } + } + + /** + * Transcribe audio buffer using Whisper via mana-stt + */ + async transcribe( + audioBuffer: Buffer, + filename: string, + language?: string + ): Promise { + this.logger.log(`Starting transcription for ${filename} (${audioBuffer.length} bytes)`); + + const formData = new FormData(); + // Convert Buffer to Uint8Array for Blob compatibility + const uint8Array = new Uint8Array(audioBuffer); + formData.append('file', new Blob([uint8Array]), filename); + + if (language) { + formData.append('language', language); + } + + const headers: Record = {}; + if (this.apiKey) { + headers['X-API-Key'] = this.apiKey; + } + + const response = await fetch(`${this.sttUrl}/transcribe`, { + method: 'POST', + body: formData, + headers, + signal: AbortSignal.timeout(120000), // 2 minute timeout + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`STT transcription failed: ${response.status} - ${error}`); + } + + const result = await response.json(); + + this.logger.log( + `Transcription complete: ${result.text?.length || 0} chars, language: ${result.language}, model: ${result.model}` + ); + + return { + text: result.text, + language: result.language || null, + model: result.model, + latencyMs: result.latency_ms || null, + durationSeconds: result.duration_seconds || null, + }; + } +} diff --git a/apps/mukke/apps/backend/tsconfig.json b/apps/mukke/apps/backend/tsconfig.json new file mode 100644 index 000000000..27971033a --- /dev/null +++ b/apps/mukke/apps/backend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "commonjs", + "moduleResolution": "node", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "outDir": "./dist", + "baseUrl": "./", + "rootDir": "./src", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/mukke/apps/landing/astro.config.mjs b/apps/mukke/apps/landing/astro.config.mjs new file mode 100644 index 000000000..fe89fd4b2 --- /dev/null +++ b/apps/mukke/apps/landing/astro.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; +import sitemap from '@astrojs/sitemap'; + +export default defineConfig({ + site: 'https://mukke.app', + integrations: [sitemap()], +}); diff --git a/apps/mukke/apps/landing/package.json b/apps/mukke/apps/landing/package.json new file mode 100644 index 000000000..d505d6def --- /dev/null +++ b/apps/mukke/apps/landing/package.json @@ -0,0 +1,20 @@ +{ + "name": "@mukke/landing", + "type": "module", + "version": "1.0.0", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro check && astro build", + "preview": "astro preview", + "astro": "astro", + "type-check": "astro check" + }, + "dependencies": { + "@astrojs/check": "^0.9.4", + "@astrojs/sitemap": "^3.3.0", + "@manacore/shared-landing-ui": "workspace:*", + "astro": "^5.1.1", + "typescript": "^5.7.2" + } +} diff --git a/apps/mukke/apps/landing/src/layouts/Layout.astro b/apps/mukke/apps/landing/src/layouts/Layout.astro new file mode 100644 index 000000000..3a7374496 --- /dev/null +++ b/apps/mukke/apps/landing/src/layouts/Layout.astro @@ -0,0 +1,48 @@ +--- +interface Props { + title: string; +} + +const { title } = Astro.props; +--- + + + + + + + + + + {title} + + + + + + diff --git a/apps/mukke/apps/landing/src/pages/index.astro b/apps/mukke/apps/landing/src/pages/index.astro new file mode 100644 index 000000000..0a4e0485a --- /dev/null +++ b/apps/mukke/apps/landing/src/pages/index.astro @@ -0,0 +1,212 @@ +--- +import Layout from '../layouts/Layout.astro'; +--- + + +
+ +
+
+
+
+

+ Mukke +

+

+ Your music workspace. Upload tracks, manage your library, write and sync lyrics, and + play your music. +

+ +
+
+
+ + +
+
+

+ Everything You Need for Lyric Sync +

+ +
+
+
+ + + +
+

Waveform Editor

+

+ Visualize your audio with an interactive waveform. Zoom, scroll, and navigate with + precision. +

+
+ +
+
+ + + +
+

BPM Detection

+

+ Automatic tempo detection helps you sync lyrics to the beat with snap-to-beat + functionality. +

+
+ +
+
+ + + +
+

Part Markers

+

+ Mark verses, hooks, bridges, and more. Organize your song structure visually. +

+
+ +
+
+ + + +
+

Live Sync Recording

+

+ Record timestamps in real-time as the song plays. Just tap to sync each line. +

+
+ +
+
+ + + + +
+

Karaoke Preview

+

+ Preview your synced lyrics in real-time with smooth karaoke-style highlighting. +

+
+ +
+
+ + + +
+

Multiple Exports

+

+ Export to LRC, SRT, JSON, or generate karaoke videos for social media. +

+
+
+
+
+ + +
+
+

Ready to Create?

+

+ Start syncing your lyrics today. Free to use, no credit card required. +

+ + Start Creating + +
+
+ + +
+
+

© {new Date().getFullYear()} Mukke. Part of the ManaCore ecosystem.

+
+
+
+
diff --git a/apps/mukke/apps/landing/tsconfig.json b/apps/mukke/apps/landing/tsconfig.json new file mode 100644 index 000000000..adb44640f --- /dev/null +++ b/apps/mukke/apps/landing/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "strictNullChecks": true + } +} diff --git a/apps/mukke/apps/web/.env.example b/apps/mukke/apps/web/.env.example new file mode 100644 index 000000000..f3a566531 --- /dev/null +++ b/apps/mukke/apps/web/.env.example @@ -0,0 +1,7 @@ +# Auth +PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001 +PUBLIC_MANA_CORE_AUTH_URL_CLIENT=http://localhost:3001 + +# Backend +PUBLIC_BACKEND_URL=http://localhost:3010 +PUBLIC_BACKEND_URL_CLIENT=http://localhost:3010 diff --git a/apps/mukke/apps/web/Dockerfile b/apps/mukke/apps/web/Dockerfile new file mode 100644 index 000000000..ba4ec7d3e --- /dev/null +++ b/apps/mukke/apps/web/Dockerfile @@ -0,0 +1,95 @@ +# Build stage +FROM node:20-alpine AS builder + +# Build arguments for SvelteKit static env vars +ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-auth:3001 +ARG PUBLIC_BACKEND_URL=http://mukke-backend:3010 + +# Set as environment variables for build +ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL +ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL + +# 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 needed by mukke web +COPY packages/shared-api-client ./packages/shared-api-client +COPY packages/shared-auth ./packages/shared-auth +COPY packages/shared-auth-ui ./packages/shared-auth-ui +COPY packages/shared-branding ./packages/shared-branding +COPY packages/shared-config ./packages/shared-config +COPY packages/shared-i18n ./packages/shared-i18n +COPY packages/shared-icons ./packages/shared-icons +COPY packages/shared-pwa ./packages/shared-pwa +COPY packages/shared-stores ./packages/shared-stores +COPY packages/shared-tailwind ./packages/shared-tailwind +COPY packages/shared-theme ./packages/shared-theme +COPY packages/shared-theme-ui ./packages/shared-theme-ui +COPY packages/shared-types ./packages/shared-types +COPY packages/shared-ui ./packages/shared-ui +COPY packages/shared-utils ./packages/shared-utils +COPY packages/shared-vite-config ./packages/shared-vite-config + +# Copy mukke shared package +COPY apps/mukke/packages ./apps/mukke/packages + +# Copy mukke web +COPY apps/mukke/apps/web ./apps/mukke/apps/web + +# Install dependencies +RUN pnpm install --frozen-lockfile + +# Build shared packages that need building +WORKDIR /app/packages/shared-vite-config +RUN pnpm build + +WORKDIR /app/packages/shared-auth +RUN pnpm build || true + +# Build the web app +WORKDIR /app/apps/mukke/apps/web +RUN pnpm exec svelte-kit sync +RUN pnpm build + +# Production stage +FROM node:20-alpine AS production + +# Keep same directory structure as builder so pnpm symlinks resolve correctly +WORKDIR /app/apps/mukke/apps/web + +# Copy the pnpm store that symlinks point to +COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm + +# Copy the app's node_modules +COPY --from=builder /app/apps/mukke/apps/web/node_modules ./node_modules + +# Copy built application +COPY --from=builder /app/apps/mukke/apps/web/build ./build +COPY --from=builder /app/apps/mukke/apps/web/package.json ./ + +# Copy entrypoint script +COPY apps/mukke/apps/web/docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +# Expose port +EXPOSE 5180 + +# Set environment variables +ENV NODE_ENV=production +ENV PORT=5180 +ENV HOST=0.0.0.0 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:5180/health || exit 1 + +# Run the app +ENTRYPOINT ["/docker-entrypoint.sh"] +CMD ["node", "build"] diff --git a/apps/mukke/apps/web/docker-entrypoint.sh b/apps/mukke/apps/web/docker-entrypoint.sh new file mode 100644 index 000000000..78d1cb8b5 --- /dev/null +++ b/apps/mukke/apps/web/docker-entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +# This script injects runtime environment variables into the SvelteKit build +# SvelteKit builds env vars at build time, but we need to inject them at runtime +# for Docker deployments where the container runs in different environments + +echo "Starting Mukke Web with runtime configuration..." +echo "PUBLIC_MANA_CORE_AUTH_URL_CLIENT: ${PUBLIC_MANA_CORE_AUTH_URL_CLIENT:-not set}" +echo "PUBLIC_BACKEND_URL_CLIENT: ${PUBLIC_BACKEND_URL_CLIENT:-not set}" + +# Execute the main command +exec "$@" diff --git a/apps/mukke/apps/web/package.json b/apps/mukke/apps/web/package.json new file mode 100644 index 000000000..3af59a987 --- /dev/null +++ b/apps/mukke/apps/web/package.json @@ -0,0 +1,50 @@ +{ + "name": "@mukke/web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "eslint .", + "format": "prettier --write .", + "type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@manacore/shared-pwa": "workspace:*", + "@manacore/shared-vite-config": "workspace:*", + "@sveltejs/adapter-node": "^5.0.0", + "@sveltejs/kit": "^2.47.1", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.1.7", + "@types/node": "^20.0.0", + "@vite-pwa/sveltekit": "^1.1.0", + "prettier": "^3.1.1", + "prettier-plugin-svelte": "^3.1.2", + "svelte": "^5.41.0", + "svelte-check": "^4.3.3", + "tailwindcss": "^4.1.7", + "tslib": "^2.4.1", + "typescript": "^5.9.3", + "vite": "^6.0.0" + }, + "dependencies": { + "@mukke/shared": "workspace:*", + "@manacore/shared-api-client": "workspace:*", + "@manacore/shared-auth": "workspace:*", + "@manacore/shared-auth-ui": "workspace:*", + "@manacore/shared-branding": "workspace:*", + "@manacore/shared-i18n": "workspace:*", + "@manacore/shared-icons": "workspace:*", + "@manacore/shared-stores": "workspace:*", + "@manacore/shared-tailwind": "workspace:*", + "@manacore/shared-theme": "workspace:*", + "@manacore/shared-theme-ui": "workspace:*", + "@manacore/shared-ui": "workspace:*", + "wavesurfer.js": "^7.8.0" + }, + "type": "module" +} diff --git a/apps/mukke/apps/web/src/app.css b/apps/mukke/apps/web/src/app.css new file mode 100644 index 000000000..9755be955 --- /dev/null +++ b/apps/mukke/apps/web/src/app.css @@ -0,0 +1,168 @@ +@import "tailwindcss"; +@import "@manacore/shared-tailwind/themes.css"; + +/* Scan shared packages for Tailwind classes */ +@source "../../../../packages/shared-ui/src"; +@source "../../../../packages/shared-auth-ui/src"; +@source "../../../../packages/shared-branding/src"; +@source "../../../../packages/shared-theme-ui/src"; +@source "../../../../packages/shared-theme-ui/src/components"; +@source "../../../../packages/shared-theme-ui/src/pages"; +@source "../../../../packages/shared-stores/src"; + +/* Waveform styles */ +.waveform-container { + position: relative; + width: 100%; + height: 128px; + background: var(--color-surface); + border-radius: 8px; + overflow: hidden; +} + +/* Marker colors */ +.marker-verse { background-color: #3B82F6; } +.marker-hook { background-color: #EF4444; } +.marker-bridge { background-color: #8B5CF6; } +.marker-intro { background-color: #22C55E; } +.marker-outro { background-color: #F97316; } +.marker-drop { background-color: #EC4899; } +.marker-breakdown { background-color: #14B8A6; } +.marker-custom { background-color: #6B7280; } + +/* Lyrics editor styles */ +.lyrics-editor { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + line-height: 1.8; +} + +.lyrics-line { + padding: 4px 8px; + border-radius: 4px; + transition: background-color 0.2s; +} + +.lyrics-line:hover { + background-color: var(--color-surface); +} + +.lyrics-line.active { + background-color: var(--color-primary); + color: white; +} + +.lyrics-line.synced { + border-left: 3px solid var(--color-primary); +} + +/* Karaoke animation */ +@keyframes karaoke-highlight { + 0% { background-position: 0% 50%; } + 100% { background-position: 100% 50%; } +} + +.karaoke-word { + transition: color 0.1s, transform 0.1s; +} + +.karaoke-word.active { + color: var(--color-primary); + transform: scale(1.05); +} + +.karaoke-word.past { + color: var(--color-foreground); +} + +.karaoke-word.future { + color: var(--color-foreground-secondary); +} + +/* Timeline styles */ +.timeline-ruler { + height: 24px; + background: var(--color-surface); + position: relative; +} + +.timeline-marker { + position: absolute; + top: 0; + height: 100%; + cursor: pointer; + opacity: 0.8; + transition: opacity 0.2s; +} + +.timeline-marker:hover { + opacity: 1; +} + +/* Playhead */ +.playhead { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: var(--color-primary); + z-index: 10; + pointer-events: none; +} + +.playhead::after { + content: ''; + position: absolute; + top: 0; + left: -4px; + width: 10px; + height: 10px; + background: var(--color-primary); + border-radius: 50%; +} + +/* Mobile responsive waveform */ +@media (max-width: 767px) { + .waveform-container { + height: 80px; + } + + .timeline-ruler { + height: 20px; + } + + .timeline-marker span { + font-size: 8px; + } +} + +/* Touch-friendly range inputs */ +input[type="range"] { + -webkit-appearance: none; + appearance: none; + background: transparent; + cursor: pointer; +} + +input[type="range"]::-webkit-slider-runnable-track { + background: hsl(var(--color-surface-hover)); + border-radius: 4px; + height: 8px; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + background: hsl(var(--color-primary)); + height: 16px; + width: 16px; + border-radius: 50%; + margin-top: -4px; +} + +@media (max-width: 767px) { + input[type="range"]::-webkit-slider-thumb { + height: 20px; + width: 20px; + margin-top: -6px; + } +} diff --git a/apps/mukke/apps/web/src/app.html b/apps/mukke/apps/web/src/app.html new file mode 100644 index 000000000..b37bb8116 --- /dev/null +++ b/apps/mukke/apps/web/src/app.html @@ -0,0 +1,15 @@ + + + + + + + + + Mukke + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/mukke/apps/web/src/hooks.server.ts b/apps/mukke/apps/web/src/hooks.server.ts new file mode 100644 index 000000000..6d7a5089d --- /dev/null +++ b/apps/mukke/apps/web/src/hooks.server.ts @@ -0,0 +1,27 @@ +/** + * Server Hooks for SvelteKit + * - Injects runtime environment variables for client-side use + * - Auth is handled client-side via Mana Core Auth + */ + +import type { Handle } from '@sveltejs/kit'; + +// Get client-side URLs from environment (Docker runtime) +const PUBLIC_MANA_CORE_AUTH_URL_CLIENT = + process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || ''; +const PUBLIC_BACKEND_URL_CLIENT = + process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || ''; + +export const handle: Handle = async ({ event, resolve }) => { + return resolve(event, { + transformPageChunk: ({ html }) => { + // Inject runtime environment variables into the HTML + // These will be available on window.__PUBLIC_*__ for client-side code + const envScript = ``; + return html.replace('', `${envScript}`); + }, + }); +}; diff --git a/apps/mukke/apps/web/src/lib/components/BeatLibrary.svelte b/apps/mukke/apps/web/src/lib/components/BeatLibrary.svelte new file mode 100644 index 000000000..733b4e40d --- /dev/null +++ b/apps/mukke/apps/web/src/lib/components/BeatLibrary.svelte @@ -0,0 +1,225 @@ + + +
+ {#if isLoading} +
+
+
+ {:else if error} +
+

{error}

+ +
+ {:else if beats.length === 0} +
+
+ + + +
+

No beats available in the library yet.

+

Upload your own beat instead.

+
+ {:else} +
+ {#each beats as beat} +
+ + + + +
+

{beat.title}

+
+ {#if beat.artist} + {beat.artist} + {/if} + {#if beat.genre} + + {beat.genre} + + {/if} + {#if beat.bpm} + {beat.bpm} BPM + {/if} + {formatDuration(beat.duration)} +
+ {#if beat.tags && beat.tags.length > 0} +
+ {#each beat.tags.slice(0, 3) as tag} + + {tag} + + {/each} + {#if beat.tags.length > 3} + + +{beat.tags.length - 3} more + + {/if} +
+ {/if} +
+ + + +
+ {/each} +
+ {/if} +
diff --git a/apps/mukke/apps/web/src/lib/components/BeatUploader.svelte b/apps/mukke/apps/web/src/lib/components/BeatUploader.svelte new file mode 100644 index 000000000..5d81a3161 --- /dev/null +++ b/apps/mukke/apps/web/src/lib/components/BeatUploader.svelte @@ -0,0 +1,298 @@ + + +
+ +
+ + +
+ + + {#if activeTab === 'upload'} +
+ + + {#if isUploading} +
+
+ {#if isDetectingBpm} + + + + {:else} +
+ {/if} +
+

+ {isDetectingBpm ? 'Detecting BPM...' : 'Uploading...'} +

+
+
+
+
+ {:else} + + {/if} + + {#if errorMessage} +

{errorMessage}

+ {/if} +
+ + + {#if isTranscribing} +
+
+
+

Transcribing lyrics...

+

+ Analyzing audio to extract lyrics automatically +

+
+
+ {:else if transcriptionError} +
+ + + +
+

Transcription failed

+

{transcriptionError}

+
+ +
+ {/if} + {:else} + + {/if} +
diff --git a/apps/mukke/apps/web/src/lib/components/FullPlayer.svelte b/apps/mukke/apps/web/src/lib/components/FullPlayer.svelte new file mode 100644 index 000000000..d783aef0c --- /dev/null +++ b/apps/mukke/apps/web/src/lib/components/FullPlayer.svelte @@ -0,0 +1,219 @@ + + +{#if playerStore.showFullPlayer && playerStore.currentSong} +
+ +
+ +
Now Playing
+
+
+ + +
+ +
+ + + +
+ + +
+
+ {playerStore.currentSong.title} +
+
+ {playerStore.currentSong.artist || 'Unknown Artist'} +
+ {#if playerStore.currentSong.album} +
+ {playerStore.currentSong.album} +
+ {/if} +
+ + +
+ +
+ {formatTime(playerStore.currentTime)} + {formatTime(playerStore.duration)} +
+
+ + +
+ + + + + + + + +
+ + +
+ + + + + + + +
+ + + + +
+ + + +
+
+
+{/if} diff --git a/apps/mukke/apps/web/src/lib/components/KaraokePreview.svelte b/apps/mukke/apps/web/src/lib/components/KaraokePreview.svelte new file mode 100644 index 000000000..92622df2c --- /dev/null +++ b/apps/mukke/apps/web/src/lib/components/KaraokePreview.svelte @@ -0,0 +1,109 @@ + + +
+ {#each visibleLines as line} +
+ {#if line.relativeIndex === 0} + +
+ + {line.text} + + + + {line.text} + +
+ {:else if line.relativeIndex < 0} + + {line.text} + {:else} + + {line.text} + {/if} +
+ {/each} + + {#if projectStore.currentLines.length === 0} +

No synced lyrics to preview.

+ {/if} +
diff --git a/apps/mukke/apps/web/src/lib/components/LyricsEditor.svelte b/apps/mukke/apps/web/src/lib/components/LyricsEditor.svelte new file mode 100644 index 000000000..eb7912ebe --- /dev/null +++ b/apps/mukke/apps/web/src/lib/components/LyricsEditor.svelte @@ -0,0 +1,214 @@ + + +
+ +
+

Lyrics

+
+ + +
+
+ + +
+ + +
+ + +
+ {#if editorStore.mode === 'edit'} + + + {:else} + +
+ {#each projectStore.currentLines as line, index} +
handleLineClick(index)} + onkeydown={(e) => e.key === 'Enter' && handleLineClick(index)} + class="lyrics-line w-full text-left flex items-center gap-3 cursor-pointer {activeLineIndex === + index + ? 'active' + : ''} {line.startTime !== null ? 'synced' : ''} {editorStore.selectedLineIndex === + index + ? 'ring-2 ring-primary' + : ''}" + > + + + {line.startTime !== null && line.startTime !== undefined + ? formatTimeWithMs(line.startTime) + : '--:--'} + + + + {line.text} + + + {#if editorStore.isRecordingTimestamps} + + {:else if line.startTime === null || line.startTime === undefined} + + {/if} +
+ {/each} + + {#if projectStore.currentLines.length === 0} +

+ No lyrics yet. Switch to Edit mode to add lyrics. +

+ {/if} +
+ {/if} +
+
diff --git a/apps/mukke/apps/web/src/lib/components/MarkerTimeline.svelte b/apps/mukke/apps/web/src/lib/components/MarkerTimeline.svelte new file mode 100644 index 000000000..e053ce4ef --- /dev/null +++ b/apps/mukke/apps/web/src/lib/components/MarkerTimeline.svelte @@ -0,0 +1,204 @@ + + +
+ +
+
+ Markers + + + + + +
+ + +
+ {#each markerTypes.slice(0, 5) as type} +
+ + {type} +
+ {/each} +
+
+ + +
+ + {#each projectStore.currentMarkers as marker} + + {/each} + + + {#if audioStore.duration > 0} +
+ {/if} +
+ + + {#if editorStore.selectedMarkerId} + {@const selectedMarker = projectStore.currentMarkers.find( + (m) => m.id === editorStore.selectedMarkerId + )} + {#if selectedMarker} +
+
+ + {selectedMarker.type} + {#if selectedMarker.label} + - {selectedMarker.label} + {/if} +
+
+ + {selectedMarker.startTime.toFixed(2)}s - {( + selectedMarker.endTime || selectedMarker.startTime + ).toFixed(2)}s + + + +
+
+ {/if} + {/if} +
diff --git a/apps/mukke/apps/web/src/lib/components/MiniPlayer.svelte b/apps/mukke/apps/web/src/lib/components/MiniPlayer.svelte new file mode 100644 index 000000000..d3409057e --- /dev/null +++ b/apps/mukke/apps/web/src/lib/components/MiniPlayer.svelte @@ -0,0 +1,78 @@ + + +{#if playerStore.currentSong} +
+ +
+
+
+ +
+ + + + +
+ + + + + +
+
+
+{/if} diff --git a/apps/mukke/apps/web/src/lib/components/PlaybackControls.svelte b/apps/mukke/apps/web/src/lib/components/PlaybackControls.svelte new file mode 100644 index 000000000..17f87cd21 --- /dev/null +++ b/apps/mukke/apps/web/src/lib/components/PlaybackControls.svelte @@ -0,0 +1,204 @@ + + +{#if compact} + +
+ + + + +
+ {formatTime(audioStore.currentTime)} +
+ + +
+ +
+ + +
+ {formatTime(audioStore.duration)} +
+ + + {#if audioStore.bpm} +
+ {audioStore.bpm} +
+ {/if} +
+{:else} + +
+ +
+ {formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)} +
+ + +
+ + + + + +
+ + +
+ +
+ + +
+ + + {Math.round(editorStore.zoom * 100)}% + + +
+ + + {#if audioStore.bpm} +
+ {audioStore.bpm} BPM +
+ {/if} +
+{/if} diff --git a/apps/mukke/apps/web/src/lib/components/QueuePanel.svelte b/apps/mukke/apps/web/src/lib/components/QueuePanel.svelte new file mode 100644 index 000000000..8b627cd27 --- /dev/null +++ b/apps/mukke/apps/web/src/lib/components/QueuePanel.svelte @@ -0,0 +1,114 @@ + + +{#if playerStore.showQueue} + + + + +
+ +
+

Queue

+ +
+ + +
+ {#if playerStore.queue.length === 0} +
Queue is empty
+ {:else} + {#each playerStore.queue as song, index} +
+ +
+ {#if index === playerStore.currentIndex} +
+ + + +
+ {:else} + {index + 1} + {/if} +
+ + + + + + {#if index !== playerStore.currentIndex} + + {/if} +
+ {/each} + {/if} +
+
+{/if} diff --git a/apps/mukke/apps/web/src/lib/components/WaveformEditor.svelte b/apps/mukke/apps/web/src/lib/components/WaveformEditor.svelte new file mode 100644 index 000000000..f3beddb8b --- /dev/null +++ b/apps/mukke/apps/web/src/lib/components/WaveformEditor.svelte @@ -0,0 +1,235 @@ + + +
+ {#if !audioStore.isLoaded && audioUrl} +
+
+
+ {/if} +
diff --git a/apps/mukke/apps/web/src/lib/stores/audio.svelte.ts b/apps/mukke/apps/web/src/lib/stores/audio.svelte.ts new file mode 100644 index 000000000..cd830bbf9 --- /dev/null +++ b/apps/mukke/apps/web/src/lib/stores/audio.svelte.ts @@ -0,0 +1,81 @@ +interface AudioState { + isPlaying: boolean; + currentTime: number; + duration: number; + isLoaded: boolean; + bpm: number | null; + audioUrl: string | null; +} + +function createAudioStore() { + let state = $state({ + isPlaying: false, + currentTime: 0, + duration: 0, + isLoaded: false, + bpm: null, + audioUrl: null, + }); + + return { + get isPlaying() { + return state.isPlaying; + }, + get currentTime() { + return state.currentTime; + }, + get duration() { + return state.duration; + }, + get isLoaded() { + return state.isLoaded; + }, + get bpm() { + return state.bpm; + }, + get audioUrl() { + return state.audioUrl; + }, + + setPlaying(playing: boolean) { + state.isPlaying = playing; + }, + + setCurrentTime(time: number) { + state.currentTime = time; + }, + + setDuration(duration: number) { + state.duration = duration; + }, + + setLoaded(loaded: boolean) { + state.isLoaded = loaded; + }, + + setBpm(bpm: number | null) { + state.bpm = bpm; + }, + + setAudioUrl(url: string | null) { + state.audioUrl = url; + if (!url) { + state.isLoaded = false; + state.duration = 0; + state.currentTime = 0; + state.isPlaying = false; + } + }, + + reset() { + state.isPlaying = false; + state.currentTime = 0; + state.duration = 0; + state.isLoaded = false; + state.bpm = null; + state.audioUrl = null; + }, + }; +} + +export const audioStore = createAudioStore(); diff --git a/apps/mukke/apps/web/src/lib/stores/auth.svelte.ts b/apps/mukke/apps/web/src/lib/stores/auth.svelte.ts new file mode 100644 index 000000000..cbc6e8d6e --- /dev/null +++ b/apps/mukke/apps/web/src/lib/stores/auth.svelte.ts @@ -0,0 +1,253 @@ +/** + * Auth Store - Manages authentication state using Svelte 5 runes + * Uses Mana Core Auth + */ + +import { browser } from '$app/environment'; +import { initializeWebAuth } from '@manacore/shared-auth'; +import type { UserData } from '@manacore/shared-auth'; + +// Get auth URL dynamically at runtime - fallback for SSR and client +function getAuthUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string }) + .__PUBLIC_MANA_CORE_AUTH_URL__; + return injectedUrl || 'http://localhost:3001'; + } + return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; +} + +// Get backend URL dynamically at runtime +function getBackendUrl(): string { + if (browser && typeof window !== 'undefined') { + const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string }) + .__PUBLIC_BACKEND_URL__; + return injectedUrl || 'http://localhost:3010'; + } + return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3010'; +} + +// Lazy initialization to avoid SSR issues with localStorage +let _authService: ReturnType['authService'] | null = null; +let _tokenManager: ReturnType['tokenManager'] | null = null; + +function getAuthService() { + if (!browser) return null; + if (!_authService) { + const auth = initializeWebAuth({ + baseUrl: getAuthUrl(), + backendUrl: getBackendUrl(), + }); + _authService = auth.authService; + _tokenManager = auth.tokenManager; + } + return _authService; +} + +function getTokenManager() { + if (!browser) return null; + getAuthService(); + return _tokenManager; +} + +// State +let user = $state(null); +let loading = $state(true); +let initialized = $state(false); + +export const authStore = { + get user() { + return user; + }, + get isLoading() { + return loading; + }, + get isAuthenticated() { + return !!user; + }, + get initialized() { + return initialized; + }, + + async initialize() { + if (initialized) return; + + const authService = getAuthService(); + if (!authService) { + initialized = true; + loading = false; + return; + } + + loading = true; + try { + let authenticated = await authService.isAuthenticated(); + + if (!authenticated) { + const ssoResult = await authService.trySSO(); + if (ssoResult.success) { + authenticated = true; + } + } + + if (authenticated) { + const userData = await authService.getUserFromToken(); + user = userData; + } + initialized = true; + } catch (error) { + console.error('Failed to initialize auth:', error); + user = null; + } finally { + loading = false; + } + }, + + async signIn(email: string, password: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.signIn(email, password); + + if (!result.success) { + return { success: false, error: result.error || 'Login failed' }; + } + + const userData = await authService.getUserFromToken(); + user = userData; + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + + async signUp(email: string, password: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server', needsVerification: false }; + } + + try { + const sourceAppUrl = browser ? window.location.origin : undefined; + const result = await authService.signUp(email, password, sourceAppUrl); + + if (!result.success) { + return { success: false, error: result.error || 'Signup failed', needsVerification: false }; + } + + if (result.needsVerification) { + return { success: true, needsVerification: true }; + } + + const signInResult = await this.signIn(email, password); + return { ...signInResult, needsVerification: false }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage, needsVerification: false }; + } + }, + + async signOut() { + const authService = getAuthService(); + if (!authService) { + user = null; + return; + } + + try { + await authService.signOut(); + user = null; + } catch (error) { + console.error('Sign out error:', error); + user = null; + } + }, + + async resendVerificationEmail(email: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const sourceAppUrl = browser ? window.location.origin : undefined; + const result = await authService.resendVerificationEmail(email, sourceAppUrl); + + if (!result.success) { + return { success: false, error: result.error || 'Failed to resend verification email' }; + } + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + + async resetPassword(email: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const sourceAppUrl = browser ? window.location.origin : undefined; + const result = await authService.forgotPassword(email, sourceAppUrl); + + if (!result.success) { + return { success: false, error: result.error || 'Failed to send reset email' }; + } + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + + async resetPasswordWithToken(token: string, newPassword: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth not available on server' }; + } + + try { + const result = await authService.resetPassword(token, newPassword); + + if (!result.success) { + return { success: false, error: result.error || 'Failed to reset password' }; + } + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: errorMessage }; + } + }, + + async getValidToken(): Promise { + const tokenManager = getTokenManager(); + if (!tokenManager) { + return null; + } + return await tokenManager.getValidToken(); + }, + + getAuthHeaders(): Record { + const authService = getAuthService(); + if (!authService) return {}; + + // Get token synchronously from storage if available + const token = + typeof localStorage !== 'undefined' ? localStorage.getItem('manacore_access_token') : null; + if (token) { + return { Authorization: `Bearer ${token}` }; + } + return {}; + }, +}; diff --git a/apps/mukke/apps/web/src/lib/stores/editor.svelte.ts b/apps/mukke/apps/web/src/lib/stores/editor.svelte.ts new file mode 100644 index 000000000..15bd982fe --- /dev/null +++ b/apps/mukke/apps/web/src/lib/stores/editor.svelte.ts @@ -0,0 +1,160 @@ +import type { MarkerType } from '@mukke/shared'; + +type EditorMode = 'edit' | 'preview'; +type SyncMode = 'line' | 'word'; + +interface EditorState { + mode: EditorMode; + syncMode: SyncMode; + selectedMarkerId: string | null; + selectedLineIndex: number | null; + isRecordingTimestamps: boolean; + zoom: number; + scrollPosition: number; + markerTypeToCreate: MarkerType; + snapToBeat: boolean; + showWaveform: boolean; + showMarkers: boolean; + showLyrics: boolean; + loopRegionId: string | null; + isLooping: boolean; +} + +function createEditorStore() { + let state = $state({ + mode: 'edit', + syncMode: 'line', + selectedMarkerId: null, + selectedLineIndex: null, + isRecordingTimestamps: false, + zoom: 1, + scrollPosition: 0, + markerTypeToCreate: 'verse', + snapToBeat: true, + showWaveform: true, + showMarkers: true, + showLyrics: true, + loopRegionId: null, + isLooping: false, + }); + + return { + get mode() { + return state.mode; + }, + get syncMode() { + return state.syncMode; + }, + get selectedMarkerId() { + return state.selectedMarkerId; + }, + get selectedLineIndex() { + return state.selectedLineIndex; + }, + get isRecordingTimestamps() { + return state.isRecordingTimestamps; + }, + get zoom() { + return state.zoom; + }, + get scrollPosition() { + return state.scrollPosition; + }, + get markerTypeToCreate() { + return state.markerTypeToCreate; + }, + get snapToBeat() { + return state.snapToBeat; + }, + get showWaveform() { + return state.showWaveform; + }, + get showMarkers() { + return state.showMarkers; + }, + get showLyrics() { + return state.showLyrics; + }, + get loopRegionId() { + return state.loopRegionId; + }, + get isLooping() { + return state.isLooping; + }, + + setMode(mode: EditorMode) { + state.mode = mode; + }, + + setSyncMode(syncMode: SyncMode) { + state.syncMode = syncMode; + }, + + selectMarker(markerId: string | null) { + state.selectedMarkerId = markerId; + }, + + selectLine(lineIndex: number | null) { + state.selectedLineIndex = lineIndex; + }, + + setRecordingTimestamps(recording: boolean) { + state.isRecordingTimestamps = recording; + }, + + setZoom(zoom: number) { + state.zoom = Math.max(0.5, Math.min(10, zoom)); + }, + + zoomIn() { + state.zoom = Math.min(10, state.zoom * 1.25); + }, + + zoomOut() { + state.zoom = Math.max(0.5, state.zoom / 1.25); + }, + + setScrollPosition(position: number) { + state.scrollPosition = position; + }, + + setMarkerTypeToCreate(type: MarkerType) { + state.markerTypeToCreate = type; + }, + + toggleSnapToBeat() { + state.snapToBeat = !state.snapToBeat; + }, + + toggleWaveform() { + state.showWaveform = !state.showWaveform; + }, + + toggleMarkers() { + state.showMarkers = !state.showMarkers; + }, + + toggleLyrics() { + state.showLyrics = !state.showLyrics; + }, + + setLoopRegion(markerId: string | null) { + state.loopRegionId = markerId; + state.isLooping = markerId !== null; + }, + + reset() { + state.mode = 'edit'; + state.syncMode = 'line'; + state.selectedMarkerId = null; + state.selectedLineIndex = null; + state.isRecordingTimestamps = false; + state.zoom = 1; + state.scrollPosition = 0; + state.loopRegionId = null; + state.isLooping = false; + }, + }; +} + +export const editorStore = createEditorStore(); diff --git a/apps/mukke/apps/web/src/lib/stores/library.svelte.ts b/apps/mukke/apps/web/src/lib/stores/library.svelte.ts new file mode 100644 index 000000000..64252abfa --- /dev/null +++ b/apps/mukke/apps/web/src/lib/stores/library.svelte.ts @@ -0,0 +1,255 @@ +import type { + Song, + Album, + Artist, + Genre, + LibraryStats, + SortField, + SortDirection, +} from '@mukke/shared'; +import { authStore } from './auth.svelte'; + +interface LibraryState { + songs: Song[]; + albums: Album[]; + artists: Artist[]; + genres: Genre[]; + stats: LibraryStats | null; + activeTab: 'songs' | 'albums' | 'artists' | 'genres'; + sortField: SortField; + sortDirection: SortDirection; + isLoading: boolean; + error: string | null; +} + +function getBackendUrl(): string { + let baseUrl = 'http://localhost:3010'; + if (typeof window !== 'undefined') { + baseUrl = + (window as unknown as { __PUBLIC_BACKEND_URL__: string }).__PUBLIC_BACKEND_URL__ || + 'http://localhost:3010'; + } + // Ensure API prefix is included + return baseUrl.endsWith('/api/v1') ? baseUrl : `${baseUrl}/api/v1`; +} + +function createLibraryStore() { + let state = $state({ + songs: [], + albums: [], + artists: [], + genres: [], + stats: null, + activeTab: 'songs', + sortField: 'addedAt' as SortField, + sortDirection: 'desc' as SortDirection, + isLoading: false, + error: null, + }); + + async function fetchApi(path: string, options: RequestInit = {}): Promise { + const response = await fetch(`${getBackendUrl()}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...authStore.getAuthHeaders(), + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || 'Request failed'); + } + + return response.json(); + } + + return { + get songs() { + return state.songs; + }, + get albums() { + return state.albums; + }, + get artists() { + return state.artists; + }, + get genres() { + return state.genres; + }, + get stats() { + return state.stats; + }, + get activeTab() { + return state.activeTab; + }, + get sortField() { + return state.sortField; + }, + get sortDirection() { + return state.sortDirection; + }, + get isLoading() { + return state.isLoading; + }, + get error() { + return state.error; + }, + + async loadSongs() { + state.isLoading = true; + state.error = null; + try { + const data = await fetchApi<{ songs: Song[] }>( + `/songs?sort=${state.sortField}&direction=${state.sortDirection}` + ); + state.songs = data.songs; + } catch (e) { + state.error = e instanceof Error ? e.message : 'Failed to load songs'; + } + state.isLoading = false; + }, + + async loadAlbums() { + state.isLoading = true; + state.error = null; + try { + const data = await fetchApi<{ albums: Album[] }>('/library/albums'); + state.albums = data.albums; + } catch (e) { + state.error = e instanceof Error ? e.message : 'Failed to load albums'; + } + state.isLoading = false; + }, + + async loadArtists() { + state.isLoading = true; + state.error = null; + try { + const data = await fetchApi<{ artists: Artist[] }>('/library/artists'); + state.artists = data.artists; + } catch (e) { + state.error = e instanceof Error ? e.message : 'Failed to load artists'; + } + state.isLoading = false; + }, + + async loadGenres() { + state.isLoading = true; + state.error = null; + try { + const data = await fetchApi<{ genres: Genre[] }>('/library/genres'); + state.genres = data.genres; + } catch (e) { + state.error = e instanceof Error ? e.message : 'Failed to load genres'; + } + state.isLoading = false; + }, + + async loadStats() { + try { + const data = await fetchApi<{ stats: LibraryStats }>('/library/stats'); + state.stats = data.stats; + } catch (e) { + state.error = e instanceof Error ? e.message : 'Failed to load stats'; + } + }, + + async loadAll() { + state.isLoading = true; + state.error = null; + try { + const [songsData, statsData] = await Promise.all([ + fetchApi<{ songs: Song[] }>( + `/songs?sort=${state.sortField}&direction=${state.sortDirection}` + ), + fetchApi<{ stats: LibraryStats }>('/library/stats'), + ]); + state.songs = songsData.songs; + state.stats = statsData.stats; + } catch (e) { + state.error = e instanceof Error ? e.message : 'Failed to load library'; + } + state.isLoading = false; + }, + + async toggleFavorite(id: string) { + const data = await fetchApi<{ song: Song }>(`/songs/${id}/favorite`, { + method: 'PUT', + }); + state.songs = state.songs.map((s) => (s.id === id ? data.song : s)); + return data.song; + }, + + async incrementPlayCount(id: string) { + const data = await fetchApi<{ song: Song }>(`/songs/${id}/play`, { + method: 'PUT', + }); + state.songs = state.songs.map((s) => (s.id === id ? data.song : s)); + return data.song; + }, + + async searchSongs(query: string) { + const data = await fetchApi<{ songs: Song[] }>( + `/songs/search?q=${encodeURIComponent(query)}` + ); + return data.songs; + }, + + async deleteSong(id: string) { + await fetchApi(`/songs/${id}`, { method: 'DELETE' }); + state.songs = state.songs.filter((s) => s.id !== id); + }, + + setActiveTab(tab: 'songs' | 'albums' | 'artists' | 'genres') { + state.activeTab = tab; + if (tab === 'songs' && state.songs.length === 0) { + this.loadSongs(); + } else if (tab === 'albums' && state.albums.length === 0) { + this.loadAlbums(); + } else if (tab === 'artists' && state.artists.length === 0) { + this.loadArtists(); + } else if (tab === 'genres' && state.genres.length === 0) { + this.loadGenres(); + } + }, + + async setSortField(field: SortField) { + state.sortField = field; + await this.loadSongs(); + }, + + async setSortDirection(direction: SortDirection) { + state.sortDirection = direction; + await this.loadSongs(); + }, + + async uploadSong(file: File) { + const uploadData = await fetchApi<{ song: Song; uploadUrl: string }>('/songs/upload', { + method: 'POST', + body: JSON.stringify({ filename: file.name }), + }); + + await fetch(uploadData.uploadUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type }, + }); + + state.songs = [uploadData.song, ...state.songs]; + return uploadData.song; + }, + + async updateSongMetadata(id: string, data: Partial) { + const result = await fetchApi<{ song: Song }>(`/songs/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + state.songs = state.songs.map((s) => (s.id === id ? result.song : s)); + return result.song; + }, + }; +} + +export const libraryStore = createLibraryStore(); diff --git a/apps/mukke/apps/web/src/lib/stores/player.svelte.ts b/apps/mukke/apps/web/src/lib/stores/player.svelte.ts new file mode 100644 index 000000000..450397ff7 --- /dev/null +++ b/apps/mukke/apps/web/src/lib/stores/player.svelte.ts @@ -0,0 +1,354 @@ +import type { Song } from '@mukke/shared'; +import { authStore } from './auth.svelte'; + +type RepeatMode = 'off' | 'all' | 'one'; + +interface PlayerState { + currentSong: Song | null; + isPlaying: boolean; + currentTime: number; + duration: number; + volume: number; + repeatMode: RepeatMode; + shuffleOn: boolean; + queue: Song[]; + originalQueue: Song[]; + currentIndex: number; + showFullPlayer: boolean; + showQueue: boolean; +} + +function getBackendUrl(): string { + let baseUrl = 'http://localhost:3010'; + if (typeof window !== 'undefined') { + baseUrl = + (window as unknown as { __PUBLIC_BACKEND_URL__: string }).__PUBLIC_BACKEND_URL__ || + 'http://localhost:3010'; + } + return baseUrl.endsWith('/api/v1') ? baseUrl : `${baseUrl}/api/v1`; +} + +function shuffleArray(arr: T[], keepIndex: number): T[] { + const result = [...arr]; + // Fisher-Yates shuffle, keeping the item at keepIndex at position 0 + if (keepIndex >= 0 && keepIndex < result.length) { + [result[0], result[keepIndex]] = [result[keepIndex], result[0]]; + } + for (let i = result.length - 1; i > 1; i--) { + const j = 1 + Math.floor(Math.random() * i); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + +function createPlayerStore() { + let state = $state({ + currentSong: null, + isPlaying: false, + currentTime: 0, + duration: 0, + volume: 1, + repeatMode: 'off', + shuffleOn: false, + queue: [], + originalQueue: [], + currentIndex: 0, + showFullPlayer: false, + showQueue: false, + }); + + let audio: HTMLAudioElement | null = null; + + if (typeof window !== 'undefined') { + audio = new Audio(); + audio.addEventListener('timeupdate', () => { + state.currentTime = audio!.currentTime; + }); + audio.addEventListener('loadedmetadata', () => { + state.duration = audio!.duration; + }); + audio.addEventListener('ended', () => { + handleNext(); + }); + } + + async function fetchApi(path: string, options: RequestInit = {}): Promise { + const response = await fetch(`${getBackendUrl()}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...authStore.getAuthHeaders(), + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || 'Request failed'); + } + + return response.json(); + } + + async function getDownloadUrl(songId: string): Promise { + const data = await fetchApi<{ url: string }>(`/songs/${songId}/download-url`); + return data.url; + } + + function getNextIndex(): number | null { + if (state.queue.length === 0) return null; + + if (state.repeatMode === 'one') { + return state.currentIndex; + } + + if (state.currentIndex < state.queue.length - 1) { + return state.currentIndex + 1; + } + + if (state.repeatMode === 'all') { + return 0; + } + + return null; + } + + function getPreviousIndex(): number | null { + if (state.queue.length === 0) return null; + + if (state.repeatMode === 'one') { + return state.currentIndex; + } + + if (state.currentIndex > 0) { + return state.currentIndex - 1; + } + + if (state.repeatMode === 'all') { + return state.queue.length - 1; + } + + return null; + } + + function updateMediaSession(song: Song) { + if (typeof navigator !== 'undefined' && 'mediaSession' in navigator) { + navigator.mediaSession.metadata = new MediaMetadata({ + title: song.title, + artist: song.artist || 'Unknown', + album: song.album || '', + }); + navigator.mediaSession.setActionHandler('play', () => store.togglePlay()); + navigator.mediaSession.setActionHandler('pause', () => store.togglePlay()); + navigator.mediaSession.setActionHandler('nexttrack', () => store.nextSong()); + navigator.mediaSession.setActionHandler('previoustrack', () => store.previousSong()); + } + } + + async function loadAndPlay(song: Song) { + if (!audio) return; + + state.currentSong = song; + state.currentTime = 0; + state.duration = 0; + + try { + const url = await getDownloadUrl(song.id); + audio.src = url; + await audio.play(); + state.isPlaying = true; + updateMediaSession(song); + } catch (e) { + console.error('Failed to play song:', e); + state.isPlaying = false; + } + } + + function handleNext() { + const nextIdx = getNextIndex(); + if (nextIdx !== null) { + state.currentIndex = nextIdx; + loadAndPlay(state.queue[nextIdx]); + } else { + state.isPlaying = false; + if (audio) { + audio.pause(); + } + } + } + + const store = { + get currentSong() { + return state.currentSong; + }, + get isPlaying() { + return state.isPlaying; + }, + get currentTime() { + return state.currentTime; + }, + get duration() { + return state.duration; + }, + get volume() { + return state.volume; + }, + get repeatMode() { + return state.repeatMode; + }, + get shuffleOn() { + return state.shuffleOn; + }, + get queue() { + return state.queue; + }, + get originalQueue() { + return state.originalQueue; + }, + get currentIndex() { + return state.currentIndex; + }, + get showFullPlayer() { + return state.showFullPlayer; + }, + get showQueue() { + return state.showQueue; + }, + + async playSong(song: Song, queue?: Song[], startIndex?: number) { + if (queue) { + state.originalQueue = [...queue]; + state.queue = [...queue]; + state.currentIndex = startIndex ?? 0; + + if (state.shuffleOn) { + state.queue = shuffleArray(state.queue, state.currentIndex); + state.currentIndex = 0; + } + } + + await loadAndPlay(song); + }, + + async playQueue(songs: Song[], startIndex: number) { + state.originalQueue = [...songs]; + state.queue = [...songs]; + state.currentIndex = startIndex; + + if (state.shuffleOn) { + state.queue = shuffleArray(state.queue, startIndex); + state.currentIndex = 0; + } + + await loadAndPlay(state.queue[state.currentIndex]); + }, + + togglePlay() { + if (!audio || !state.currentSong) return; + + if (state.isPlaying) { + audio.pause(); + state.isPlaying = false; + } else { + audio.play(); + state.isPlaying = true; + } + }, + + seekTo(time: number) { + if (!audio) return; + audio.currentTime = time; + state.currentTime = time; + }, + + setVolume(vol: number) { + if (!audio) return; + const clamped = Math.max(0, Math.min(1, vol)); + audio.volume = clamped; + state.volume = clamped; + }, + + async nextSong() { + handleNext(); + }, + + async previousSong() { + if (state.currentTime > 3) { + store.seekTo(0); + return; + } + + const prevIdx = getPreviousIndex(); + if (prevIdx !== null) { + state.currentIndex = prevIdx; + await loadAndPlay(state.queue[prevIdx]); + } + }, + + toggleShuffle() { + state.shuffleOn = !state.shuffleOn; + + if (state.shuffleOn) { + const currentSong = state.queue[state.currentIndex]; + state.queue = shuffleArray(state.queue, state.currentIndex); + state.currentIndex = 0; + // Verify current song is at 0 + if (state.queue[0]?.id !== currentSong?.id) { + const idx = state.queue.findIndex((s) => s.id === currentSong?.id); + if (idx >= 0) { + [state.queue[0], state.queue[idx]] = [state.queue[idx], state.queue[0]]; + } + } + } else { + const currentSong = state.queue[state.currentIndex]; + state.queue = [...state.originalQueue]; + const idx = state.queue.findIndex((s) => s.id === currentSong?.id); + state.currentIndex = idx >= 0 ? idx : 0; + } + }, + + toggleRepeat() { + const modes: RepeatMode[] = ['off', 'all', 'one']; + const currentIdx = modes.indexOf(state.repeatMode); + state.repeatMode = modes[(currentIdx + 1) % modes.length]; + }, + + toggleFullPlayer() { + state.showFullPlayer = !state.showFullPlayer; + }, + + toggleQueue() { + state.showQueue = !state.showQueue; + }, + + clearQueue() { + if (audio) { + audio.pause(); + audio.src = ''; + } + state.currentSong = null; + state.isPlaying = false; + state.currentTime = 0; + state.duration = 0; + state.queue = []; + state.originalQueue = []; + state.currentIndex = 0; + state.showFullPlayer = false; + state.showQueue = false; + }, + + removeFromQueue(index: number) { + if (index === state.currentIndex) return; + + state.queue = state.queue.filter((_, i) => i !== index); + + if (index < state.currentIndex) { + state.currentIndex--; + } + }, + }; + + return store; +} + +export const playerStore = createPlayerStore(); diff --git a/apps/mukke/apps/web/src/lib/stores/playlist.svelte.ts b/apps/mukke/apps/web/src/lib/stores/playlist.svelte.ts new file mode 100644 index 000000000..f99909600 --- /dev/null +++ b/apps/mukke/apps/web/src/lib/stores/playlist.svelte.ts @@ -0,0 +1,156 @@ +import type { Playlist, PlaylistWithSongs } from '@mukke/shared'; +import { authStore } from './auth.svelte'; + +interface PlaylistState { + playlists: Playlist[]; + currentPlaylist: PlaylistWithSongs | null; + isLoading: boolean; + error: string | null; +} + +function getBackendUrl(): string { + let baseUrl = 'http://localhost:3010'; + if (typeof window !== 'undefined') { + baseUrl = + (window as unknown as { __PUBLIC_BACKEND_URL__: string }).__PUBLIC_BACKEND_URL__ || + 'http://localhost:3010'; + } + // Ensure API prefix is included + return baseUrl.endsWith('/api/v1') ? baseUrl : `${baseUrl}/api/v1`; +} + +function createPlaylistStore() { + let state = $state({ + playlists: [], + currentPlaylist: null, + isLoading: false, + error: null, + }); + + async function fetchApi(path: string, options: RequestInit = {}): Promise { + const response = await fetch(`${getBackendUrl()}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...authStore.getAuthHeaders(), + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || 'Request failed'); + } + + return response.json(); + } + + return { + get playlists() { + return state.playlists; + }, + get currentPlaylist() { + return state.currentPlaylist; + }, + get isLoading() { + return state.isLoading; + }, + get error() { + return state.error; + }, + + async loadPlaylists() { + state.isLoading = true; + state.error = null; + try { + const data = await fetchApi<{ playlists: Playlist[] }>('/playlists'); + state.playlists = data.playlists; + } catch (e) { + state.error = e instanceof Error ? e.message : 'Failed to load playlists'; + } + state.isLoading = false; + }, + + async loadPlaylist(id: string) { + state.isLoading = true; + state.error = null; + try { + const data = await fetchApi<{ playlist: PlaylistWithSongs }>(`/playlists/${id}`); + state.currentPlaylist = data.playlist; + } catch (e) { + state.error = e instanceof Error ? e.message : 'Failed to load playlist'; + } + state.isLoading = false; + }, + + async createPlaylist(name: string, description?: string) { + const data = await fetchApi<{ playlist: Playlist }>('/playlists', { + method: 'POST', + body: JSON.stringify({ name, description }), + }); + state.playlists = [data.playlist, ...state.playlists]; + return data.playlist; + }, + + async updatePlaylist(id: string, updates: { name?: string; description?: string }) { + const data = await fetchApi<{ playlist: Playlist }>(`/playlists/${id}`, { + method: 'PUT', + body: JSON.stringify(updates), + }); + state.playlists = state.playlists.map((p) => (p.id === id ? data.playlist : p)); + if (state.currentPlaylist?.id === id) { + state.currentPlaylist = { ...state.currentPlaylist, ...data.playlist }; + } + return data.playlist; + }, + + async deletePlaylist(id: string) { + await fetchApi(`/playlists/${id}`, { method: 'DELETE' }); + state.playlists = state.playlists.filter((p) => p.id !== id); + if (state.currentPlaylist?.id === id) { + state.currentPlaylist = null; + } + }, + + async addSong(playlistId: string, songId: string) { + const data = await fetchApi<{ playlist: PlaylistWithSongs }>( + `/playlists/${playlistId}/songs`, + { + method: 'POST', + body: JSON.stringify({ songId }), + } + ); + if (state.currentPlaylist?.id === playlistId) { + state.currentPlaylist = data.playlist; + } + return data.playlist; + }, + + async removeSong(playlistId: string, songId: string) { + const data = await fetchApi<{ playlist: PlaylistWithSongs }>( + `/playlists/${playlistId}/songs/${songId}`, + { method: 'DELETE' } + ); + if (state.currentPlaylist?.id === playlistId) { + state.currentPlaylist = data.playlist; + } + return data.playlist; + }, + + async reorderSongs(playlistId: string, songIds: string[]) { + const data = await fetchApi<{ playlist: PlaylistWithSongs }>( + `/playlists/${playlistId}/songs/reorder`, + { + method: 'PUT', + body: JSON.stringify({ songIds }), + } + ); + if (state.currentPlaylist?.id === playlistId) { + state.currentPlaylist = data.playlist; + } + return data.playlist; + }, + }; +} + +export const playlistStore = createPlaylistStore(); diff --git a/apps/mukke/apps/web/src/lib/stores/project.svelte.ts b/apps/mukke/apps/web/src/lib/stores/project.svelte.ts new file mode 100644 index 000000000..b8043212a --- /dev/null +++ b/apps/mukke/apps/web/src/lib/stores/project.svelte.ts @@ -0,0 +1,286 @@ +import type { Project, Beat, Lyrics, LyricLine, Marker } from '@mukke/shared'; +import { authStore } from './auth.svelte'; + +interface ProjectState { + projects: Project[]; + currentProject: Project | null; + currentBeat: Beat | null; + currentLyrics: Lyrics | null; + currentLines: LyricLine[]; + currentMarkers: Marker[]; + isLoading: boolean; + error: string | null; +} + +function getBackendUrl(): string { + let baseUrl = 'http://localhost:3010'; + if (typeof window !== 'undefined') { + baseUrl = + (window as unknown as { __PUBLIC_BACKEND_URL__: string }).__PUBLIC_BACKEND_URL__ || + 'http://localhost:3010'; + } + // Ensure API prefix is included + return baseUrl.endsWith('/api/v1') ? baseUrl : `${baseUrl}/api/v1`; +} + +function createProjectStore() { + let state = $state({ + projects: [], + currentProject: null, + currentBeat: null, + currentLyrics: null, + currentLines: [], + currentMarkers: [], + isLoading: false, + error: null, + }); + + async function fetchApi(path: string, options: RequestInit = {}): Promise { + const response = await fetch(`${getBackendUrl()}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...authStore.getAuthHeaders(), + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || 'Request failed'); + } + + return response.json(); + } + + return { + get projects() { + return state.projects; + }, + get currentProject() { + return state.currentProject; + }, + get currentBeat() { + return state.currentBeat; + }, + get currentLyrics() { + return state.currentLyrics; + }, + get currentLines() { + return state.currentLines; + }, + get currentMarkers() { + return state.currentMarkers; + }, + get isLoading() { + return state.isLoading; + }, + get error() { + return state.error; + }, + + async loadProjects() { + state.isLoading = true; + state.error = null; + try { + const data = await fetchApi<{ projects: Project[] }>('/projects'); + state.projects = data.projects; + } catch (e) { + state.error = e instanceof Error ? e.message : 'Failed to load projects'; + } + state.isLoading = false; + }, + + async loadProject(id: string) { + state.isLoading = true; + state.error = null; + try { + const data = await fetchApi<{ + project: Project & { beat: Beat | null; lyrics: Lyrics | null }; + }>(`/projects/${id}`); + state.currentProject = data.project; + state.currentBeat = data.project.beat; + state.currentLyrics = data.project.lyrics; + + // Load markers if beat exists + if (data.project.beat) { + const markersData = await fetchApi<{ markers: Marker[] }>( + `/markers/beat/${data.project.beat.id}` + ); + state.currentMarkers = markersData.markers; + } + + // Load lyrics lines if lyrics exists + if (data.project.lyrics) { + const lyricsData = await fetchApi<{ lyrics: { lines: LyricLine[] } | null }>( + `/lyrics/project/${id}` + ); + state.currentLines = lyricsData.lyrics?.lines || []; + } + } catch (e) { + state.error = e instanceof Error ? e.message : 'Failed to load project'; + } + state.isLoading = false; + }, + + async createProject(title: string, description?: string) { + const data = await fetchApi<{ project: Project }>('/projects', { + method: 'POST', + body: JSON.stringify({ title, description }), + }); + state.projects = [data.project, ...state.projects]; + return data.project; + }, + + async updateProject(id: string, updates: { title?: string; description?: string }) { + const data = await fetchApi<{ project: Project }>(`/projects/${id}`, { + method: 'PUT', + body: JSON.stringify(updates), + }); + state.projects = state.projects.map((p) => (p.id === id ? data.project : p)); + if (state.currentProject?.id === id) { + state.currentProject = data.project; + } + return data.project; + }, + + async deleteProject(id: string) { + await fetchApi(`/projects/${id}`, { method: 'DELETE' }); + state.projects = state.projects.filter((p) => p.id !== id); + if (state.currentProject?.id === id) { + state.currentProject = null; + state.currentBeat = null; + state.currentLyrics = null; + state.currentLines = []; + state.currentMarkers = []; + } + }, + + async uploadBeat(projectId: string, file: File) { + // Get upload URL + const uploadData = await fetchApi<{ beat: Beat; uploadUrl: string }>('/beats/upload', { + method: 'POST', + body: JSON.stringify({ projectId, filename: file.name }), + }); + + // Upload file to S3 + await fetch(uploadData.uploadUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type }, + }); + + state.currentBeat = uploadData.beat; + return uploadData.beat; + }, + + async updateBeatMetadata( + beatId: string, + metadata: { duration?: number; bpm?: number; bpmConfidence?: number; waveformData?: unknown } + ) { + const data = await fetchApi<{ beat: Beat }>(`/beats/${beatId}/metadata`, { + method: 'PUT', + body: JSON.stringify(metadata), + }); + state.currentBeat = data.beat; + return data.beat; + }, + + async getBeatDownloadUrl(beatId: string): Promise { + const data = await fetchApi<{ url: string }>(`/beats/${beatId}/download-url`); + return data.url; + }, + + async deleteBeat(beatId: string) { + await fetchApi(`/beats/${beatId}`, { method: 'DELETE' }); + state.currentBeat = null; + state.currentMarkers = []; + }, + + async checkSttAvailable(): Promise { + try { + const data = await fetchApi<{ available: boolean }>('/beats/stt/available'); + return data.available; + } catch { + return false; + } + }, + + async transcribeBeat(beatId: string): Promise<{ beat: Beat; lyrics: string | null }> { + const data = await fetchApi<{ beat: Beat; lyrics: string | null }>( + `/beats/${beatId}/transcribe`, + { method: 'POST' } + ); + state.currentBeat = data.beat; + if (data.lyrics) { + state.currentLyrics = { ...state.currentLyrics!, content: data.lyrics }; + } + return data; + }, + + async updateLyrics(projectId: string, content: string) { + const data = await fetchApi<{ lyrics: Lyrics }>(`/lyrics/project/${projectId}`, { + method: 'POST', + body: JSON.stringify({ content }), + }); + state.currentLyrics = data.lyrics; + return data.lyrics; + }, + + async syncLines( + lyricsId: string, + lines: Array<{ lineNumber: number; text: string; startTime?: number; endTime?: number }> + ) { + const data = await fetchApi<{ lines: LyricLine[] }>(`/lyrics/${lyricsId}/sync`, { + method: 'POST', + body: JSON.stringify({ lines }), + }); + state.currentLines = data.lines; + return data.lines; + }, + + async updateLineTimestamp(lineId: string, startTime?: number, endTime?: number) { + const data = await fetchApi<{ line: LyricLine }>(`/lyrics/line/${lineId}/timestamp`, { + method: 'PUT', + body: JSON.stringify({ startTime, endTime }), + }); + state.currentLines = state.currentLines.map((l) => (l.id === lineId ? data.line : l)); + return data.line; + }, + + async createMarker(beatId: string, marker: Omit) { + const data = await fetchApi<{ marker: Marker }>('/markers', { + method: 'POST', + body: JSON.stringify({ beatId, ...marker }), + }); + state.currentMarkers = [...state.currentMarkers, data.marker].sort( + (a, b) => a.startTime - b.startTime + ); + return data.marker; + }, + + async updateMarker(markerId: string, updates: Partial) { + const data = await fetchApi<{ marker: Marker }>(`/markers/${markerId}`, { + method: 'PUT', + body: JSON.stringify(updates), + }); + state.currentMarkers = state.currentMarkers.map((m) => (m.id === markerId ? data.marker : m)); + return data.marker; + }, + + async deleteMarker(markerId: string) { + await fetchApi(`/markers/${markerId}`, { method: 'DELETE' }); + state.currentMarkers = state.currentMarkers.filter((m) => m.id !== markerId); + }, + + clearCurrent() { + state.currentProject = null; + state.currentBeat = null; + state.currentLyrics = null; + state.currentLines = []; + state.currentMarkers = []; + }, + }; +} + +export const projectStore = createProjectStore(); diff --git a/apps/mukke/apps/web/src/lib/stores/theme.svelte.ts b/apps/mukke/apps/web/src/lib/stores/theme.svelte.ts new file mode 100644 index 000000000..b56b5b32a --- /dev/null +++ b/apps/mukke/apps/web/src/lib/stores/theme.svelte.ts @@ -0,0 +1,15 @@ +import { createThemeStore, type HSLValue, type ThemeVariant } from '@manacore/shared-theme'; + +/** + * Mukke theme store + * + * Uses blue primary color matching the waveform progress color + */ +export const theme = createThemeStore({ + appId: 'mukke', + defaultVariant: 'ocean' as ThemeVariant, + primaryColor: { + light: '217 91% 60%' as HSLValue, // Blue #3b82f6 + dark: '217 91% 60%' as HSLValue, + }, +}); diff --git a/apps/mukke/apps/web/src/lib/utils/bpm-detector.ts b/apps/mukke/apps/web/src/lib/utils/bpm-detector.ts new file mode 100644 index 000000000..791dd448b --- /dev/null +++ b/apps/mukke/apps/web/src/lib/utils/bpm-detector.ts @@ -0,0 +1,234 @@ +/** + * BPM Detection using Web Audio API + * Uses peak detection algorithm for BPM estimation + * + * Note: For more accurate results, consider using essentia.js WASM module + * This implementation provides a lightweight fallback + */ + +interface BpmResult { + bpm: number; + confidence: number; +} + +/** + * Detect BPM from an audio buffer + */ +export async function detectBpm(audioBuffer: AudioBuffer): Promise { + // Get audio data from the first channel + const channelData = audioBuffer.getChannelData(0); + const sampleRate = audioBuffer.sampleRate; + + // Downsample for efficiency + const downsampleFactor = 4; + const downsampled = downsample(channelData, downsampleFactor); + const effectiveSampleRate = sampleRate / downsampleFactor; + + // Apply low-pass filter to focus on bass frequencies (kick drum) + const filtered = lowPassFilter(downsampled, effectiveSampleRate, 150); + + // Detect peaks + const peaks = detectPeaks(filtered, effectiveSampleRate); + + // Calculate intervals between peaks + const intervals = calculateIntervals(peaks, effectiveSampleRate); + + // Estimate BPM from intervals + const result = estimateBpm(intervals); + + return result; +} + +/** + * Detect BPM from a File object + */ +export async function detectBpmFromFile(file: File): Promise { + const arrayBuffer = await file.arrayBuffer(); + const audioContext = new AudioContext(); + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + const result = await detectBpm(audioBuffer); + await audioContext.close(); + return result; +} + +/** + * Detect BPM from a URL + */ +export async function detectBpmFromUrl(url: string): Promise { + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + const audioContext = new AudioContext(); + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + const result = await detectBpm(audioBuffer); + await audioContext.close(); + return result; +} + +function downsample(data: Float32Array, factor: number): Float32Array { + const length = Math.floor(data.length / factor); + const result = new Float32Array(length); + for (let i = 0; i < length; i++) { + result[i] = data[i * factor]; + } + return result; +} + +function lowPassFilter(data: Float32Array, sampleRate: number, cutoff: number): Float32Array { + const rc = 1.0 / (cutoff * 2 * Math.PI); + const dt = 1.0 / sampleRate; + const alpha = dt / (rc + dt); + + const result = new Float32Array(data.length); + result[0] = data[0]; + + for (let i = 1; i < data.length; i++) { + result[i] = result[i - 1] + alpha * (data[i] - result[i - 1]); + } + + return result; +} + +function detectPeaks(data: Float32Array, sampleRate: number): number[] { + const peaks: number[] = []; + const minPeakDistance = Math.floor(sampleRate * 0.2); // Min 200ms between peaks (300 BPM max) + + // Calculate threshold as percentage of max amplitude + let maxAmplitude = 0; + for (let i = 0; i < data.length; i++) { + const abs = Math.abs(data[i]); + if (abs > maxAmplitude) maxAmplitude = abs; + } + const threshold = maxAmplitude * 0.5; + + let lastPeak = -minPeakDistance; + + for (let i = 1; i < data.length - 1; i++) { + if (i - lastPeak < minPeakDistance) continue; + + const current = Math.abs(data[i]); + const prev = Math.abs(data[i - 1]); + const next = Math.abs(data[i + 1]); + + if (current > threshold && current > prev && current > next) { + peaks.push(i); + lastPeak = i; + } + } + + return peaks; +} + +function calculateIntervals(peaks: number[], sampleRate: number): number[] { + const intervals: number[] = []; + + for (let i = 1; i < peaks.length; i++) { + const interval = (peaks[i] - peaks[i - 1]) / sampleRate; + // Filter to reasonable BPM range (60-200 BPM = 0.3-1.0 seconds) + if (interval >= 0.3 && interval <= 1.0) { + intervals.push(interval); + } + } + + return intervals; +} + +function estimateBpm(intervals: number[]): BpmResult { + if (intervals.length === 0) { + return { bpm: 120, confidence: 0 }; + } + + // Group intervals into buckets and find the most common + const bucketSize = 0.02; // 20ms buckets + const buckets: Map = new Map(); + + for (const interval of intervals) { + const bucket = Math.round(interval / bucketSize) * bucketSize; + if (!buckets.has(bucket)) { + buckets.set(bucket, []); + } + buckets.get(bucket)!.push(interval); + } + + // Find the bucket with most intervals + let maxCount = 0; + let bestBucket = 0.5; + let bestIntervals: number[] = []; + + for (const [bucket, bucketIntervals] of buckets) { + if (bucketIntervals.length > maxCount) { + maxCount = bucketIntervals.length; + bestBucket = bucket; + bestIntervals = bucketIntervals; + } + } + + // Calculate average interval from best bucket + const avgInterval = bestIntervals.reduce((a, b) => a + b, 0) / bestIntervals.length; + const bpm = Math.round(60 / avgInterval); + + // Calculate confidence based on how many intervals fell into the best bucket + const confidence = Math.min(1, (maxCount / intervals.length) * 2); + + // Ensure BPM is in reasonable range + let finalBpm = bpm; + if (finalBpm < 60) finalBpm *= 2; + if (finalBpm > 200) finalBpm /= 2; + + return { + bpm: Math.round(finalBpm), + confidence: Math.round(confidence * 100) / 100, + }; +} + +/** + * Snap a time value to the nearest beat based on BPM + */ +export function snapToBeat(time: number, bpm: number, offset: number = 0): number { + const beatDuration = 60 / bpm; + const adjustedTime = time - offset; + const nearestBeat = Math.round(adjustedTime / beatDuration) * beatDuration; + return nearestBeat + offset; +} + +/** + * Get beat times within a range + */ +export function getBeatTimes( + startTime: number, + endTime: number, + bpm: number, + offset: number = 0 +): number[] { + const beatDuration = 60 / bpm; + const beats: number[] = []; + + const firstBeat = Math.ceil((startTime - offset) / beatDuration) * beatDuration + offset; + + for (let beat = firstBeat; beat <= endTime; beat += beatDuration) { + beats.push(beat); + } + + return beats; +} + +/** + * Get bar (measure) times within a range (assuming 4/4 time) + */ +export function getBarTimes( + startTime: number, + endTime: number, + bpm: number, + offset: number = 0, + beatsPerBar: number = 4 +): number[] { + const barDuration = (60 / bpm) * beatsPerBar; + const bars: number[] = []; + + const firstBar = Math.ceil((startTime - offset) / barDuration) * barDuration + offset; + + for (let bar = firstBar; bar <= endTime; bar += barDuration) { + bars.push(bar); + } + + return bars; +} diff --git a/apps/mukke/apps/web/src/lib/utils/time-format.ts b/apps/mukke/apps/web/src/lib/utils/time-format.ts new file mode 100644 index 000000000..284f84f93 --- /dev/null +++ b/apps/mukke/apps/web/src/lib/utils/time-format.ts @@ -0,0 +1,44 @@ +/** + * Format time in seconds to MM:SS format + */ +export function formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +/** + * Format time in seconds to MM:SS.ms format + */ +export function formatTimeWithMs(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + const ms = Math.floor((seconds % 1) * 100); + return `${mins}:${secs.toString().padStart(2, '0')}.${ms.toString().padStart(2, '0')}`; +} + +/** + * Parse MM:SS or MM:SS.ms format to seconds + */ +export function parseTime(timeString: string): number | null { + const match = timeString.match(/^(\d+):(\d{2})(?:\.(\d{2}))?$/); + if (!match) return null; + + const mins = parseInt(match[1], 10); + const secs = parseInt(match[2], 10); + const ms = match[3] ? parseInt(match[3], 10) / 100 : 0; + + return mins * 60 + secs + ms; +} + +/** + * Format duration for display (e.g., "3:45") + */ +export function formatDuration(seconds: number): string { + if (seconds < 60) { + return `0:${Math.floor(seconds).toString().padStart(2, '0')}`; + } + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; +} diff --git a/apps/mukke/apps/web/src/routes/(app)/+layout.svelte b/apps/mukke/apps/web/src/routes/(app)/+layout.svelte new file mode 100644 index 000000000..cc3943919 --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(app)/+layout.svelte @@ -0,0 +1,192 @@ + + +{#if !authStore.isAuthenticated} +
+
+
+{:else} +
+ + {#if sidebarOpen} + + {/if} + + + + + +
+ +
+ + Mukke +
+ + +
+ {@render children()} +
+ + + +
+
+ + + + + + +{/if} diff --git a/apps/mukke/apps/web/src/routes/(app)/dashboard/+page.svelte b/apps/mukke/apps/web/src/routes/(app)/dashboard/+page.svelte new file mode 100644 index 000000000..2f8cbbeee --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(app)/dashboard/+page.svelte @@ -0,0 +1,169 @@ + + + + Dashboard - Mukke + + +
+

Welcome to Mukke

+ + +
+

Library Stats

+ {#if statsLoading} +
+ {#each Array(4) as _} +
+
+
+
+ {/each} +
+ {:else if libraryStore.stats} +
+
+

Songs

+

{libraryStore.stats.totalSongs}

+
+
+

Albums

+

{libraryStore.stats.totalAlbums}

+
+
+

Artists

+

{libraryStore.stats.totalArtists}

+
+
+

Genres

+

{libraryStore.stats.totalGenres}

+
+
+ {:else} +

+ No library data yet. Upload some songs to get started. +

+ {/if} +
+ + +
+

Quick Actions

+ +
+ + +
+
+

Recent Projects

+ View all +
+ {#if projectsLoading} +
+ {#each Array(3) as _} +
+
+
+
+
+ {/each} +
+ {:else if projectStore.projects.length === 0} +
+ + + +

No projects yet

+ Create your first project +
+ {:else} + + {/if} +
+
diff --git a/apps/mukke/apps/web/src/routes/(app)/editor/[id]/+page.svelte b/apps/mukke/apps/web/src/routes/(app)/editor/[id]/+page.svelte new file mode 100644 index 000000000..a927e3e8b --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(app)/editor/[id]/+page.svelte @@ -0,0 +1,491 @@ + + + + {projectStore.currentProject?.title || 'Editor'} - Mukke + + + + +
+ +
+
+
+ + + + + +
+

+ {projectStore.currentProject?.title || 'Loading...'} +

+ {#if projectStore.currentProject?.description && !isMobile} +

+ {projectStore.currentProject.description} +

+ {/if} +
+
+ +
+ + + + +
+ + + {#if showExportMenu} +
+ + + +
+ {/if} +
+
+
+
+ + {#if projectStore.isLoading} +
+
+
+ {:else if projectStore.error} +
+
+

{projectStore.error}

+ Go back +
+
+ {:else} + +
+ +
+ {#if projectStore.currentBeat} +
+
+
+ + + + {projectStore.currentBeat.filename} +
+ +
+ + + + {#if !isMobile} + waveformEditor?.toggleLoop(markerId)} + /> + {/if} + + +
+ {:else} + + {/if} +
+ + + {#if isMobile} + +
+ +
+ + +
+ + +
+ {#if mobileTab === 'lyrics'} + + {:else} + + {/if} +
+
+ {:else} + +
+ +
+ +
+ + +
+ {#if editorStore.mode === 'preview'} + + {:else} +
+
+

Switch to Preview mode to see karaoke animation

+ +
+
+ {/if} +
+
+ {/if} +
+ {/if} +
+ + +{#if showExportMenu} + +{/if} diff --git a/apps/mukke/apps/web/src/routes/(app)/library/+page.svelte b/apps/mukke/apps/web/src/routes/(app)/library/+page.svelte new file mode 100644 index 000000000..e7e72912a --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(app)/library/+page.svelte @@ -0,0 +1,294 @@ + + + + Library - Mukke + + +
+

Library

+ + +
+ {#each tabs as tab} + + {/each} +
+ + + {#if libraryStore.isLoading} +
+
+
+ {:else if libraryStore.error} +
+

{libraryStore.error}

+ +
+ {:else} + + {#if libraryStore.activeTab === 'songs'} + {#if libraryStore.songs.length === 0} +
+ + + +

No songs in your library

+ Upload your first song +
+ {:else} +
+ +
+ Title + Artist + Album + Duration + + +
+ + {#each libraryStore.songs as song} +
+ {song.title} + {song.artist ?? 'Unknown'} + {song.album ?? 'Unknown'} + + {formatDuration(song.duration)} + + + +
+ {/each} +
+ {/if} + {/if} + + + {#if libraryStore.activeTab === 'albums'} + {#if libraryStore.albums.length === 0} +
+

No albums found

+
+ {:else} + + {/if} + {/if} + + + {#if libraryStore.activeTab === 'artists'} + {#if libraryStore.artists.length === 0} +
+

No artists found

+
+ {:else} + + {/if} + {/if} + + + {#if libraryStore.activeTab === 'genres'} + {#if libraryStore.genres.length === 0} +
+

No genres found

+
+ {:else} + + {/if} + {/if} + {/if} +
diff --git a/apps/mukke/apps/web/src/routes/(app)/library/albums/[name]/+page.svelte b/apps/mukke/apps/web/src/routes/(app)/library/albums/[name]/+page.svelte new file mode 100644 index 000000000..4a24c4e0a --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(app)/library/albums/[name]/+page.svelte @@ -0,0 +1,164 @@ + + + + {albumName} - Mukke + + +
+ + + + + + Back to Library + + + {#if isLoading} +
+
+
+ {:else if error} +
+

{error}

+
+ {:else} + +
+
+ + + +
+
+

{albumName}

+

{albumArtist}

+

+ {#if albumYear}{albumYear} · + {/if} + {songs.length} + {songs.length === 1 ? 'song' : 'songs'} +

+ +
+
+ + + {#if songs.length > 0} +
+
+ # + Title + Artist + Duration +
+ {#each songs as song, i} + + {/each} +
+ {/if} + {/if} +
diff --git a/apps/mukke/apps/web/src/routes/(app)/library/artists/[name]/+page.svelte b/apps/mukke/apps/web/src/routes/(app)/library/artists/[name]/+page.svelte new file mode 100644 index 000000000..9d3b0c7ab --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(app)/library/artists/[name]/+page.svelte @@ -0,0 +1,153 @@ + + + + {artistName} - Mukke + + +
+ + + + + + Back to Library + + + {#if isLoading} +
+
+
+ {:else if error} +
+

{error}

+
+ {:else} + +
+
+ + + +
+
+

{artistName}

+

+ {songs.length} + {songs.length === 1 ? 'song' : 'songs'} +

+ +
+
+ + + {#if songs.length > 0} +
+
+ Title + Album + Duration +
+ {#each songs as song, i} + + {/each} +
+ {/if} + {/if} +
diff --git a/apps/mukke/apps/web/src/routes/(app)/library/genres/[name]/+page.svelte b/apps/mukke/apps/web/src/routes/(app)/library/genres/[name]/+page.svelte new file mode 100644 index 000000000..f774ce0cd --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(app)/library/genres/[name]/+page.svelte @@ -0,0 +1,136 @@ + + + + {genreName} - Mukke + + +
+ + + + + + Back to Library + + + {#if isLoading} +
+
+
+ {:else if error} +
+

{error}

+
+ {:else} + +
+

{genreName}

+

+ {songs.length} + {songs.length === 1 ? 'song' : 'songs'} +

+ +
+ + + {#if songs.length > 0} +
+
+ Title + Artist + Duration +
+ {#each songs as song, i} + + {/each} +
+ {/if} + {/if} +
diff --git a/apps/mukke/apps/web/src/routes/(app)/playlists/+page.svelte b/apps/mukke/apps/web/src/routes/(app)/playlists/+page.svelte new file mode 100644 index 000000000..34d5804d4 --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(app)/playlists/+page.svelte @@ -0,0 +1,198 @@ + + + + Playlists - Mukke + + +
+
+

Playlists

+ +
+ + {#if playlistStore.isLoading} +
+
+
+ {:else if playlistStore.error} +
+

{playlistStore.error}

+ +
+ {:else if playlistStore.playlists.length === 0} +
+ + + +

No playlists yet

+ +
+ {:else} + + {/if} +
+ + +{#if showCreateModal} +
+
+

Create Playlist

+
{ + e.preventDefault(); + handleCreate(); + }} + > +
+ + +
+
+ + +
+
+ + +
+
+
+
+{/if} diff --git a/apps/mukke/apps/web/src/routes/(app)/playlists/[id]/+page.svelte b/apps/mukke/apps/web/src/routes/(app)/playlists/[id]/+page.svelte new file mode 100644 index 000000000..03ae348b7 --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(app)/playlists/[id]/+page.svelte @@ -0,0 +1,252 @@ + + + + {playlistStore.currentPlaylist?.name ?? 'Playlist'} - Mukke + + +
+ + + + + + Back to Playlists + + + {#if playlistStore.isLoading} +
+
+
+ {:else if playlistStore.error} +
+

{playlistStore.error}

+ +
+ {:else if playlistStore.currentPlaylist} + +
+ {#if isEditingName} + saveName()} + class="text-2xl font-bold bg-transparent border-b-2 border-primary focus:outline-none w-full" + autofocus + /> + {:else} +

+ {playlistStore.currentPlaylist.name} +

+ {/if} + {#if playlistStore.currentPlaylist.description} +

+ {playlistStore.currentPlaylist.description} +

+ {/if} +

+ {playlistStore.currentPlaylist.songs.length} + {playlistStore.currentPlaylist.songs.length === 1 ? 'song' : 'songs'} +

+
+ + +
+ + + + + + + Add Songs + +
+ + + {#if playlistStore.currentPlaylist.songs.length === 0} +
+ + + +

No songs in this playlist

+ + Browse your library to add songs + +
+ {:else} +
+ +
+ Title + Artist + Album + Duration + +
+ + {#each playlistStore.currentPlaylist.songs as song, index} +
handlePlaySong(song, index)} + role="button" + tabindex="0" + onkeydown={(e) => { + if (e.key === 'Enter') handlePlaySong(song, index); + }} + > + {song.title} + {song.artist ?? 'Unknown'} + {song.album ?? 'Unknown'} + + {formatDuration(song.duration)} + + +
+ {/each} +
+ {/if} + {/if} +
diff --git a/apps/mukke/apps/web/src/routes/(app)/projects/+page.svelte b/apps/mukke/apps/web/src/routes/(app)/projects/+page.svelte new file mode 100644 index 000000000..02f88fd96 --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(app)/projects/+page.svelte @@ -0,0 +1,199 @@ + + + + Editor Projects - Mukke + + +
+
+

Editor Projects

+ +
+ + {#if projectStore.isLoading} +
+
+
+ {:else if projectStore.projects.length === 0} +
+
+ + + +
+

No projects yet

+

Create your first project to get started

+ +
+ {:else} + + {/if} +
+ + +{#if showCreateModal} +
(showCreateModal = false)} + role="dialog" + > +
e.stopPropagation()} + role="document" + > +

Create New Project

+
{ + e.preventDefault(); + handleCreateProject(); + }} + > +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+{/if} diff --git a/apps/mukke/apps/web/src/routes/(app)/search/+page.svelte b/apps/mukke/apps/web/src/routes/(app)/search/+page.svelte new file mode 100644 index 000000000..19a39b140 --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(app)/search/+page.svelte @@ -0,0 +1,249 @@ + + + + Search - Mukke + + +
+

Search

+ + +
+ + + + +
+ + + {#if isSearching} +
+
+
+ {:else if !hasSearched} + +
+ + + +

Search your music library

+
+ {:else if results.length === 0} + +
+

No results found for "{query}"

+
+ {:else} + +

+ {results.length} + {results.length === 1 ? 'result' : 'results'} +

+
+ +
+ Title + Artist + Album + Duration + +
+ + {#each results as song, index} +
handlePlaySong(song, index)} + role="button" + tabindex="0" + onkeydown={(e) => { + if (e.key === 'Enter') handlePlaySong(song, index); + }} + > + {song.title} + {song.artist ?? 'Unknown'} + {song.album ?? 'Unknown'} + + {formatDuration(song.duration)} + +
+ + + +
+ + {#if activePlaylistDropdown === song.id} +
+ {#if playlistStore.playlists.length === 0} +

No playlists

+ {:else} + {#each playlistStore.playlists as playlist} + + {/each} + {/if} +
+ {/if} +
+ + e.stopPropagation()} + class="p-1 text-foreground-secondary hover:text-foreground transition-colors" + title="Open in editor" + > + + + + +
+
+ {/each} +
+ {/if} +
diff --git a/apps/mukke/apps/web/src/routes/(app)/upload/+page.svelte b/apps/mukke/apps/web/src/routes/(app)/upload/+page.svelte new file mode 100644 index 000000000..e2fa400d0 --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(app)/upload/+page.svelte @@ -0,0 +1,329 @@ + + + + Upload - Mukke + + +
+

Upload Songs

+ + +
+ + + +

Drag and drop audio files here

+

or

+ +
+ + + {#if files.length > 0} +
+ {#each files as uf (uf.id)} +
+ +
+
+ {#if uf.status === 'uploading'} +
+ {:else if uf.status === 'uploaded'} + + + + {:else if uf.status === 'error'} + + + + {:else} +
+ {/if} + {uf.file.name} +
+ +
+ + + {#if uf.status === 'uploading'} +
+
+
+ {/if} + + + {#if uf.status === 'error'} +

{uf.error}

+ {/if} + + + {#if uf.status === 'uploaded'} +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ {/if} +
+ {/each} +
+ {/if} +
diff --git a/apps/mukke/apps/web/src/routes/(auth)/+layout.svelte b/apps/mukke/apps/web/src/routes/(auth)/+layout.svelte new file mode 100644 index 000000000..a54cfdcb7 --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(auth)/+layout.svelte @@ -0,0 +1,5 @@ + + +{@render children()} diff --git a/apps/mukke/apps/web/src/routes/(auth)/forgot-password/+page.svelte b/apps/mukke/apps/web/src/routes/(auth)/forgot-password/+page.svelte new file mode 100644 index 000000000..c8c71a5d7 --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(auth)/forgot-password/+page.svelte @@ -0,0 +1,29 @@ + + + + Forgot Password - Mukke + + + diff --git a/apps/mukke/apps/web/src/routes/(auth)/login/+page.svelte b/apps/mukke/apps/web/src/routes/(auth)/login/+page.svelte new file mode 100644 index 000000000..eb5d14ede --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(auth)/login/+page.svelte @@ -0,0 +1,64 @@ + + + + Login - Mukke + + + diff --git a/apps/mukke/apps/web/src/routes/(auth)/register/+page.svelte b/apps/mukke/apps/web/src/routes/(auth)/register/+page.svelte new file mode 100644 index 000000000..079f15a77 --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(auth)/register/+page.svelte @@ -0,0 +1,50 @@ + + + + Register - Mukke + + + diff --git a/apps/mukke/apps/web/src/routes/(auth)/reset-password/+page.svelte b/apps/mukke/apps/web/src/routes/(auth)/reset-password/+page.svelte new file mode 100644 index 000000000..550f5f5f3 --- /dev/null +++ b/apps/mukke/apps/web/src/routes/(auth)/reset-password/+page.svelte @@ -0,0 +1,209 @@ + + + + {t.title} - Mukke + + +
+
+ + + Mukke + +
+ +
+
+
+

{t.title}

+

+ {#if success} + {t.success} + {:else if hasToken} + {t.subtitle} + {:else} + {t.invalidToken} + {/if} +

+
+ + {#if success} +
+
+
+

+ {t.successMessage} +

+ + {t.goToLogin} + +
+
+ {:else if hasToken} +
+
+ {#if error} +
+ {error} +
+ {/if} + +
+
+ + +

+ {t.minChars} +

+
+ +
+ + +
+ + +
+
+
+ {:else} +
+
+
⚠️
+

+ {t.invalidTokenMessage} +

+ + {t.requestNew} + +
+
+ {/if} +
+
+
diff --git a/apps/mukke/apps/web/src/routes/+layout.svelte b/apps/mukke/apps/web/src/routes/+layout.svelte new file mode 100644 index 000000000..7d6051e8e --- /dev/null +++ b/apps/mukke/apps/web/src/routes/+layout.svelte @@ -0,0 +1,37 @@ + + +{#if loading} +
+
+
+

Mukke

+
+
+{:else} +
+ {@render children()} +
+{/if} diff --git a/apps/mukke/apps/web/src/routes/+page.svelte b/apps/mukke/apps/web/src/routes/+page.svelte new file mode 100644 index 000000000..eb88956de --- /dev/null +++ b/apps/mukke/apps/web/src/routes/+page.svelte @@ -0,0 +1,128 @@ + + + + Mukke - Music Workspace + + +
+
+
+

+ Mukke +

+ + Login + +
+
+ +
+
+

Your Music Workspace

+

+ Upload your music, manage your library, write and sync lyrics, and play your tracks. +

+ + +
+
+
+ + + +
+

Music Library

+

+ Organize songs by album, artist, and genre. +

+
+ +
+
+ + + + +
+

Web Player

+

+ Queue, shuffle, repeat with persistent playback. +

+
+ +
+
+ + + +
+

Lyrics Editor

+

Sync lyrics with waveform visualization.

+
+ +
+
+ + + +
+

Export

+

Export to LRC, SRT, JSON formats.

+
+
+
+
+
diff --git a/apps/mukke/apps/web/src/routes/health/+server.ts b/apps/mukke/apps/web/src/routes/health/+server.ts new file mode 100644 index 000000000..582aa97ff --- /dev/null +++ b/apps/mukke/apps/web/src/routes/health/+server.ts @@ -0,0 +1,5 @@ +import { json } from '@sveltejs/kit'; + +export function GET() { + return json({ status: 'ok', service: 'mukke-web' }); +} diff --git a/apps/mukke/apps/web/src/routes/offline/+page.svelte b/apps/mukke/apps/web/src/routes/offline/+page.svelte new file mode 100644 index 000000000..75ac35d68 --- /dev/null +++ b/apps/mukke/apps/web/src/routes/offline/+page.svelte @@ -0,0 +1,104 @@ + + + + Offline - Mukke + + +
+
+
+ + + + +
+ +

+ {isOnline ? 'Verbindung wiederhergestellt!' : 'Du bist offline'} +

+ +

+ {#if isOnline} + Du wirst gleich weitergeleitet... + {:else} + Mukke benötigt eine Internetverbindung für Audio. + {/if} +

+ + {#if !isOnline} +
+ + + + + Zur Startseite + + + +
+ {:else} +
+ + + + + Weiterleitung... +
+ {/if} +
+
diff --git a/apps/mukke/apps/web/svelte.config.js b/apps/mukke/apps/web/svelte.config.js new file mode 100644 index 000000000..a7a917e4c --- /dev/null +++ b/apps/mukke/apps/web/svelte.config.js @@ -0,0 +1,14 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + out: 'build', + }), + }, +}; + +export default config; diff --git a/apps/mukke/apps/web/tsconfig.json b/apps/mukke/apps/web/tsconfig.json new file mode 100644 index 000000000..a8f10c8e3 --- /dev/null +++ b/apps/mukke/apps/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/apps/mukke/apps/web/vite.config.ts b/apps/mukke/apps/web/vite.config.ts new file mode 100644 index 000000000..2cd76c607 --- /dev/null +++ b/apps/mukke/apps/web/vite.config.ts @@ -0,0 +1,31 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; +import tailwindcss from '@tailwindcss/vite'; +import { SvelteKitPWA } from '@vite-pwa/sveltekit'; +import { createPWAConfig } from '@manacore/shared-pwa'; +import { MANACORE_SHARED_PACKAGES } from '@manacore/shared-vite-config'; + +export default defineConfig({ + plugins: [ + tailwindcss(), + sveltekit(), + SvelteKitPWA( + createPWAConfig({ + name: 'Mukke - Music Workspace', + shortName: 'Mukke', + description: 'Music Workspace - Tracks, Lyrics & Player', + themeColor: '#f97316', + }) + ), + ], + server: { + port: 5180, + strictPort: true, + }, + ssr: { + noExternal: [...MANACORE_SHARED_PACKAGES, '@mukke/shared'], + }, + optimizeDeps: { + exclude: [...MANACORE_SHARED_PACKAGES, '@mukke/shared'], + }, +}); diff --git a/apps/mukke/package.json b/apps/mukke/package.json index 44052b0ab..9d67e83e3 100644 --- a/apps/mukke/package.json +++ b/apps/mukke/package.json @@ -1,8 +1,6 @@ { "name": "mukke", - "version": "1.0.0", "private": true, - "description": "Mukke - Offline-first iOS Music Player", "scripts": { "dev": "pnpm run --filter=@mukke/* --parallel dev" } diff --git a/apps/mukke/packages/shared/package.json b/apps/mukke/packages/shared/package.json new file mode 100644 index 000000000..6ce008f4a --- /dev/null +++ b/apps/mukke/packages/shared/package.json @@ -0,0 +1,17 @@ +{ + "name": "@mukke/shared", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./types": "./src/types/index.ts" + }, + "scripts": { + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/apps/mukke/packages/shared/src/index.ts b/apps/mukke/packages/shared/src/index.ts new file mode 100644 index 000000000..fcb073fef --- /dev/null +++ b/apps/mukke/packages/shared/src/index.ts @@ -0,0 +1 @@ +export * from './types'; diff --git a/apps/mukke/packages/shared/src/types/beat.ts b/apps/mukke/packages/shared/src/types/beat.ts new file mode 100644 index 000000000..153f95185 --- /dev/null +++ b/apps/mukke/packages/shared/src/types/beat.ts @@ -0,0 +1,40 @@ +export type TranscriptionStatus = 'none' | 'pending' | 'completed' | 'failed'; + +export interface Beat { + id: string; + projectId: string; + storagePath: string; + filename?: string | null; + duration?: number | null; + bpm?: number | null; + bpmConfidence?: number | null; + waveformData?: WaveformData | null; + // STT Transcription fields + transcriptionStatus?: TranscriptionStatus | null; + transcriptionError?: string | null; + transcribedAt?: Date | null; + createdAt: Date; +} + +export interface WaveformData { + peaks: number[]; + sampleRate: number; + duration: number; +} + +export interface CreateBeatDto { + projectId: string; + filename: string; +} + +export interface UpdateBeatDto { + bpm?: number; + bpmConfidence?: number; + duration?: number; + waveformData?: WaveformData; +} + +export interface BeatUploadResponse { + beat: Beat; + uploadUrl: string; +} diff --git a/apps/mukke/packages/shared/src/types/export.ts b/apps/mukke/packages/shared/src/types/export.ts new file mode 100644 index 000000000..c01226899 --- /dev/null +++ b/apps/mukke/packages/shared/src/types/export.ts @@ -0,0 +1,57 @@ +export type ExportFormat = 'lrc' | 'srt' | 'json' | 'video'; + +export interface ExportOptions { + format: ExportFormat; + includeMarkers?: boolean; + videoOptions?: VideoExportOptions; +} + +export interface VideoExportOptions { + width: number; + height: number; + fps: number; + backgroundColor: string; + textColor: string; + highlightColor: string; + fontFamily: string; + fontSize: number; +} + +export interface LrcExportResult { + content: string; + filename: string; +} + +export interface SrtExportResult { + content: string; + filename: string; +} + +export interface JsonExportResult { + data: JsonExportData; + filename: string; +} + +export interface JsonExportData { + project: { + id: string; + title: string; + description?: string; + }; + beat: { + bpm?: number; + duration?: number; + }; + markers: Array<{ + type: string; + label?: string; + startTime: number; + endTime?: number; + }>; + lyrics: Array<{ + lineNumber: number; + text: string; + startTime?: number; + endTime?: number; + }>; +} diff --git a/apps/mukke/packages/shared/src/types/index.ts b/apps/mukke/packages/shared/src/types/index.ts new file mode 100644 index 000000000..4f43f88ce --- /dev/null +++ b/apps/mukke/packages/shared/src/types/index.ts @@ -0,0 +1,7 @@ +export * from './project'; +export * from './beat'; +export * from './marker'; +export * from './lyrics'; +export * from './export'; +export * from './song'; +export * from './playlist'; diff --git a/apps/mukke/packages/shared/src/types/lyrics.ts b/apps/mukke/packages/shared/src/types/lyrics.ts new file mode 100644 index 000000000..9bb1ee1a2 --- /dev/null +++ b/apps/mukke/packages/shared/src/types/lyrics.ts @@ -0,0 +1,55 @@ +export interface Lyrics { + id: string; + projectId: string; + content?: string | null; +} + +export interface LyricLine { + id: string; + lyricsId: string; + lineNumber: number; + text: string; + startTime?: number | null; + endTime?: number | null; +} + +export interface CreateLyricsDto { + projectId: string; + content?: string; +} + +export interface UpdateLyricsDto { + content?: string; +} + +export interface CreateLyricLineDto { + lyricsId: string; + lineNumber: number; + text: string; + startTime?: number; + endTime?: number; +} + +export interface UpdateLyricLineDto { + text?: string; + startTime?: number; + endTime?: number; +} + +export interface SyncedLyrics { + lines: SyncedLine[]; +} + +export interface SyncedLine { + lineNumber: number; + text: string; + startTime: number; + endTime?: number; + words?: SyncedWord[]; +} + +export interface SyncedWord { + word: string; + startTime: number; + endTime: number; +} diff --git a/apps/mukke/packages/shared/src/types/marker.ts b/apps/mukke/packages/shared/src/types/marker.ts new file mode 100644 index 000000000..54e3bb17c --- /dev/null +++ b/apps/mukke/packages/shared/src/types/marker.ts @@ -0,0 +1,49 @@ +export type MarkerType = + | 'verse' + | 'hook' + | 'bridge' + | 'intro' + | 'outro' + | 'drop' + | 'breakdown' + | 'custom'; + +export interface Marker { + id: string; + beatId: string; + type: MarkerType; + label?: string | null; + startTime: number; + endTime?: number | null; + color?: string | null; + sortOrder?: number | null; +} + +export interface CreateMarkerDto { + beatId: string; + type: MarkerType; + label?: string; + startTime: number; + endTime?: number; + color?: string; +} + +export interface UpdateMarkerDto { + type?: MarkerType; + label?: string; + startTime?: number; + endTime?: number; + color?: string; + sortOrder?: number; +} + +export const MARKER_COLORS: Record = { + verse: '#3B82F6', // blue + hook: '#EF4444', // red + bridge: '#8B5CF6', // purple + intro: '#22C55E', // green + outro: '#F97316', // orange + drop: '#EC4899', // pink + breakdown: '#14B8A6', // teal + custom: '#6B7280', // gray +}; diff --git a/apps/mukke/packages/shared/src/types/playlist.ts b/apps/mukke/packages/shared/src/types/playlist.ts new file mode 100644 index 000000000..b76984729 --- /dev/null +++ b/apps/mukke/packages/shared/src/types/playlist.ts @@ -0,0 +1,25 @@ +import type { Song } from './song'; + +export interface Playlist { + id: string; + userId: string; + name: string; + description: string | null; + coverArtPath: string | null; + createdAt: string; + updatedAt: string; +} + +export interface PlaylistWithSongs extends Playlist { + songs: Song[]; +} + +export interface CreatePlaylistDto { + name: string; + description?: string; +} + +export interface UpdatePlaylistDto { + name?: string; + description?: string; +} diff --git a/apps/mukke/packages/shared/src/types/project.ts b/apps/mukke/packages/shared/src/types/project.ts new file mode 100644 index 000000000..aaac4e85b --- /dev/null +++ b/apps/mukke/packages/shared/src/types/project.ts @@ -0,0 +1,18 @@ +export interface Project { + id: string; + userId: string; + title: string; + description?: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateProjectDto { + title: string; + description?: string; +} + +export interface UpdateProjectDto { + title?: string; + description?: string; +} diff --git a/apps/mukke/packages/shared/src/types/song.ts b/apps/mukke/packages/shared/src/types/song.ts new file mode 100644 index 000000000..23f933456 --- /dev/null +++ b/apps/mukke/packages/shared/src/types/song.ts @@ -0,0 +1,73 @@ +export interface Song { + id: string; + userId: string; + title: string; + artist: string | null; + album: string | null; + albumArtist: string | null; + genre: string | null; + trackNumber: number | null; + year: number | null; + duration: number | null; + storagePath: string; + coverArtPath: string | null; + fileSize: number | null; + bpm: number | null; + favorite: boolean; + playCount: number; + lastPlayedAt: string | null; + addedAt: string; + updatedAt: string; +} + +export interface Album { + album: string; + albumArtist: string | null; + year: number | null; + coverArtPath: string | null; + songCount: number; +} + +export interface Artist { + artist: string; + songCount: number; + albumCount: number; +} + +export interface Genre { + genre: string; + songCount: number; +} + +export interface LibraryStats { + totalSongs: number; + totalArtists: number; + totalAlbums: number; + totalGenres: number; + totalDuration: number | null; + totalPlays: number; +} + +export type SortField = 'title' | 'artist' | 'album' | 'addedAt' | 'playCount'; +export type SortDirection = 'asc' | 'desc'; + +export interface CreateSongDto { + title: string; + artist?: string; + album?: string; + albumArtist?: string; + genre?: string; + trackNumber?: number; + year?: number; + bpm?: number; +} + +export interface UpdateSongDto extends Partial { + duration?: number; + fileSize?: number; +} + +export interface SongUploadResponse { + song: Song; + uploadUrl: string; +} diff --git a/apps/mukke/packages/shared/tsconfig.json b/apps/mukke/packages/shared/tsconfig.json new file mode 100644 index 000000000..9976a4fcb --- /dev/null +++ b/apps/mukke/packages/shared/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/cloudflared-config.yml b/cloudflared-config.yml index 8424c3e25..9bd54c22f 100644 --- a/cloudflared-config.yml +++ b/cloudflared-config.yml @@ -70,10 +70,10 @@ ingress: - hostname: photos-api.mana.how service: http://localhost:3039 - # LightWrite App - - hostname: lightwrite.mana.how + # Mukke App + - hostname: mukke.mana.how service: http://localhost:5180 - - hostname: lightwrite-api.mana.how + - hostname: mukke-api.mana.how service: http://localhost:3010 # LLM Services diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index dba83f7ac..b0c2a01a9 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -88,6 +88,7 @@ services: mc mb --ignore-existing myminio/inventory-storage; mc mb --ignore-existing myminio/planta-storage; mc mb --ignore-existing myminio/projectdoc-storage; + mc mb --ignore-existing myminio/mukke-storage; mc anonymous set download myminio/manacore-storage; mc anonymous set download myminio/picture-storage; mc anonymous set download myminio/planta-storage; diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index c047f7889..ae63a6579 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -105,7 +105,7 @@ services: SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com} SMTP_PASSWORD: ${SMTP_PASSWORD} SMTP_FROM: Mana - CORS_ORIGINS: https://mana.how,https://chat.mana.how,https://todo.mana.how,https://calendar.mana.how,https://clock.mana.how,https://contacts.mana.how,https://storage.mana.how,https://presi.mana.how,https://nutriphi.mana.how,https://skilltree.mana.how,https://photos.mana.how,https://matrix.mana.how,https://element.mana.how,https://link.mana.how,https://playground.mana.how,https://lightwrite.mana.how,https://zitare.mana.how,https://questions.mana.how,https://planta.mana.how,https://manadeck.mana.how,https://picture.mana.how + CORS_ORIGINS: https://mana.how,https://chat.mana.how,https://todo.mana.how,https://calendar.mana.how,https://clock.mana.how,https://contacts.mana.how,https://storage.mana.how,https://presi.mana.how,https://nutriphi.mana.how,https://skilltree.mana.how,https://photos.mana.how,https://matrix.mana.how,https://element.mana.how,https://link.mana.how,https://playground.mana.how,https://mukke.mana.how,https://zitare.mana.how,https://questions.mana.how,https://planta.mana.how,https://manadeck.mana.how,https://picture.mana.how DUCKDB_PATH: /data/analytics/metrics.duckdb SYNAPSE_OIDC_CLIENT_SECRET: ${SYNAPSE_OIDC_CLIENT_SECRET:-} # Backend URLs for user data aggregation (GDPR self-service) @@ -593,12 +593,12 @@ services: retries: 3 start_period: 40s - lightwrite-backend: + mukke-backend: build: context: . - dockerfile: apps/lightwrite/apps/backend/Dockerfile - image: lightwrite-backend:local - container_name: mana-app-lightwrite-backend + dockerfile: apps/mukke/apps/backend/Dockerfile + image: mukke-backend:local + container_name: mana-app-mukke-backend restart: always depends_on: mana-auth: @@ -606,15 +606,15 @@ services: environment: NODE_ENV: production PORT: 3010 - DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/lightwrite + DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mukke MANA_CORE_AUTH_URL: http://mana-auth:3001 - CORS_ORIGINS: https://lightwrite.mana.how,https://mana.how + CORS_ORIGINS: https://mukke.mana.how,https://mana.how S3_ENDPOINT: http://minio:9000 S3_PUBLIC_ENDPOINT: https://minio.mana.how S3_REGION: us-east-1 S3_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} S3_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} - S3_BUCKET: lightwrite-storage + S3_BUCKET: mukke-storage RUN_DB_PUSH: "true" ports: - "3010:3010" @@ -1518,25 +1518,25 @@ services: retries: 3 start_period: 40s - lightwrite-web: + mukke-web: build: context: . - dockerfile: apps/lightwrite/apps/web/Dockerfile + dockerfile: apps/mukke/apps/web/Dockerfile args: - PUBLIC_BACKEND_URL: http://lightwrite-backend:3010 + PUBLIC_BACKEND_URL: http://mukke-backend:3010 PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001 - image: lightwrite-web:local - container_name: mana-app-lightwrite-web + image: mukke-web:local + container_name: mana-app-mukke-web restart: always depends_on: - lightwrite-backend: + mukke-backend: condition: service_healthy environment: NODE_ENV: production PORT: 5180 - PUBLIC_BACKEND_URL: http://lightwrite-backend:3010 + PUBLIC_BACKEND_URL: http://mukke-backend:3010 PUBLIC_MANA_CORE_AUTH_URL: http://mana-auth:3001 - PUBLIC_BACKEND_URL_CLIENT: https://lightwrite-api.mana.how + PUBLIC_BACKEND_URL_CLIENT: https://mukke-api.mana.how PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how ports: - "5180:5180" diff --git a/packages/shared-branding/src/config.ts b/packages/shared-branding/src/config.ts index 503b17a4b..c9d62097c 100644 --- a/packages/shared-branding/src/config.ts +++ b/packages/shared-branding/src/config.ts @@ -284,6 +284,32 @@ export const APP_BRANDING: Record = { logoStroke: true, logoStrokeWidth: 1.5, }, + mukke: { + id: 'mukke', + name: 'Mukke', + tagline: 'Music Workspace', + primaryColor: '#ec4899', + secondaryColor: '#f472b6', + // Music note icon + logoPath: + 'M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3', + logoViewBox: '0 0 24 24', + logoStroke: true, + logoStrokeWidth: 1.5, + }, + context: { + id: 'context', + name: 'Context', + tagline: 'Knowledge Management', + primaryColor: '#0ea5e9', + secondaryColor: '#38bdf8', + // Document/brain icon for knowledge management + logoPath: + 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z', + logoViewBox: '0 0 24 24', + logoStroke: true, + logoStrokeWidth: 1.5, + }, }; /** diff --git a/packages/shared-branding/src/index.ts b/packages/shared-branding/src/index.ts index 40ad81c33..e9216a479 100644 --- a/packages/shared-branding/src/index.ts +++ b/packages/shared-branding/src/index.ts @@ -36,6 +36,8 @@ export { PlantaLogo, PlaygroundLogo, LightWriteLogo, + MukkeLogo, + ContextLogo, } from './logos'; // Configuration diff --git a/packages/shared-branding/src/logos/MukkeLogo.svelte b/packages/shared-branding/src/logos/MukkeLogo.svelte new file mode 100644 index 000000000..e42aa8f1b --- /dev/null +++ b/packages/shared-branding/src/logos/MukkeLogo.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/shared-branding/src/logos/index.ts b/packages/shared-branding/src/logos/index.ts index 96bb5d939..45d336cbe 100644 --- a/packages/shared-branding/src/logos/index.ts +++ b/packages/shared-branding/src/logos/index.ts @@ -23,3 +23,5 @@ export { default as SkillTreeLogo } from './SkillTreeLogo.svelte'; export { default as PlantaLogo } from './PlantaLogo.svelte'; export { default as PlaygroundLogo } from './PlaygroundLogo.svelte'; export { default as LightWriteLogo } from './LightWriteLogo.svelte'; +export { default as MukkeLogo } from './MukkeLogo.svelte'; +export { default as ContextLogo } from './ContextLogo.svelte'; diff --git a/packages/shared-storage/src/factory.ts b/packages/shared-storage/src/factory.ts index 088a7d480..83e54bc88 100644 --- a/packages/shared-storage/src/factory.ts +++ b/packages/shared-storage/src/factory.ts @@ -167,3 +167,10 @@ export function createInventoryStorage(publicUrl?: string): StorageClient { export function createLightWriteStorage(): StorageClient { return createStorageClient({ name: BUCKETS.LIGHTWRITE }); } + +/** + * Create a storage client for the Mukke project + */ +export function createMukkeStorage(): StorageClient { + return createStorageClient({ name: BUCKETS.MUKKE }); +} diff --git a/packages/shared-storage/src/index.ts b/packages/shared-storage/src/index.ts index f5d462861..15d732c5c 100644 --- a/packages/shared-storage/src/index.ts +++ b/packages/shared-storage/src/index.ts @@ -17,6 +17,7 @@ export { createMailStorage, createInventoryStorage, createLightWriteStorage, + createMukkeStorage, } from './factory'; // Utilities diff --git a/packages/shared-storage/src/types.ts b/packages/shared-storage/src/types.ts index cd7691d1b..d72ef510d 100644 --- a/packages/shared-storage/src/types.ts +++ b/packages/shared-storage/src/types.ts @@ -95,6 +95,7 @@ export const BUCKETS = { MAIL: 'mail-storage', INVENTORY: 'inventory-storage', LIGHTWRITE: 'lightwrite-storage', + MUKKE: 'mukke-storage', } as const; export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS];