From 34c6b9c973dc126109ebb49882de3a61ca6f23c3 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 19 Mar 2026 14:41:18 +0100 Subject: [PATCH] feat(mukke): add ID3 tag extraction, editor UI, and tag writing Auto-extract metadata (title, artist, album, genre, year, BPM, cover art) from uploaded files using music-metadata. Add SongEditor modal for editing metadata in the library view. Support writing ID3 tags back to MP3 files via node-id3. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mukke/apps/backend/package.json | 1 + .../apps/backend/src/song/song.controller.ts | 21 ++ .../apps/backend/src/song/song.service.ts | 100 ++++++ .../web/src/lib/components/SongEditor.svelte | 294 ++++++++++++++++++ .../apps/web/src/lib/stores/library.svelte.ts | 19 ++ .../web/src/routes/(app)/library/+page.svelte | 42 ++- .../web/src/routes/(app)/upload/+page.svelte | 17 +- pnpm-lock.yaml | 34 +- 8 files changed, 512 insertions(+), 16 deletions(-) create mode 100644 apps/mukke/apps/web/src/lib/components/SongEditor.svelte diff --git a/apps/mukke/apps/backend/package.json b/apps/mukke/apps/backend/package.json index 455fcf1c3..a965629c3 100644 --- a/apps/mukke/apps/backend/package.json +++ b/apps/mukke/apps/backend/package.json @@ -38,6 +38,7 @@ "drizzle-kit": "^0.30.2", "drizzle-orm": "^0.38.3", "music-metadata": "^11.12.3", + "node-id3": "^0.2.9", "postgres": "^3.4.5", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" diff --git a/apps/mukke/apps/backend/src/song/song.controller.ts b/apps/mukke/apps/backend/src/song/song.controller.ts index db24f66e1..096853d16 100644 --- a/apps/mukke/apps/backend/src/song/song.controller.ts +++ b/apps/mukke/apps/backend/src/song/song.controller.ts @@ -66,6 +66,27 @@ export class SongController { return { url }; } + @Post(':id/extract-metadata') + async extractMetadata( + @CurrentUser() user: CurrentUserData, + @Param('id', ParseUUIDPipe) id: string + ) { + const song = await this.songService.extractMetadata(id, user.userId); + return { song }; + } + + @Post(':id/write-tags') + async writeTags(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + await this.songService.writeTags(id, user.userId); + return { success: true }; + } + + @Get(':id/cover-url') + async getCoverUrl(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { + const url = await this.songService.getCoverDownloadUrl(id, user.userId); + return { url }; + } + @Put(':id') async update( @CurrentUser() user: CurrentUserData, diff --git a/apps/mukke/apps/backend/src/song/song.service.ts b/apps/mukke/apps/backend/src/song/song.service.ts index d38ddefeb..f8a79b8af 100644 --- a/apps/mukke/apps/backend/src/song/song.service.ts +++ b/apps/mukke/apps/backend/src/song/song.service.ts @@ -1,5 +1,6 @@ import { Injectable, Inject, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { eq, and, desc, asc, ilike, or, sql } from 'drizzle-orm'; +import NodeID3 from 'node-id3'; import { DATABASE_CONNECTION } from '../db/database.module'; import { Database } from '../db/connection'; import { songs } from '../db/schema'; @@ -196,6 +197,105 @@ export class SongService { return this.storage.getDownloadUrl(song.storagePath, { expiresIn: 3600 }); } + async extractMetadata(id: string, userId: string): Promise { + const song = await this.findByIdOrThrow(id, userId); + + const buffer = await this.storage.download(song.storagePath); + const { parseBuffer } = await import('music-metadata'); + const metadata = await parseBuffer(buffer); + + const updateData: Record = {}; + const { common, format } = metadata; + + if (common.title) updateData.title = common.title; + if (common.artist) updateData.artist = common.artist; + if (common.album) updateData.album = common.album; + if (common.albumartist) updateData.albumArtist = common.albumartist; + if (common.genre?.[0]) updateData.genre = common.genre[0]; + if (common.year) updateData.year = common.year; + if (common.track?.no) updateData.trackNumber = common.track.no; + if (common.bpm) updateData.bpm = common.bpm; + if (format.duration) updateData.duration = format.duration; + updateData.fileSize = buffer.length; + + // Extract cover art + const picture = common.picture?.[0]; + if (picture) { + try { + const ext = picture.format.includes('png') ? 'png' : 'jpg'; + const coverKey = generateUserFileKey(userId, `covers/${id}.${ext}`); + await this.storage.upload(coverKey, Buffer.from(picture.data), { + contentType: picture.format, + public: true, + }); + updateData.coverArtPath = coverKey; + } catch (e) { + this.logger.warn(`Failed to extract cover art for song ${id}: ${e}`); + } + } + + updateData.updatedAt = new Date(); + + const [updatedSong] = await this.db + .update(songs) + .set(updateData) + .where(and(eq(songs.id, id), eq(songs.userId, userId))) + .returning(); + + return updatedSong; + } + + async writeTags(id: string, userId: string): Promise { + const song = await this.findByIdOrThrow(id, userId); + + if (!song.storagePath.toLowerCase().endsWith('.mp3')) { + throw new BadRequestException('ID3 tag writing is only supported for MP3 files'); + } + + const buffer = await this.storage.download(song.storagePath); + + const tags: NodeID3.Tags = {}; + if (song.title) tags.title = song.title; + if (song.artist) tags.artist = song.artist; + if (song.album) tags.album = song.album; + if (song.albumArtist) tags.performerInfo = song.albumArtist; + if (song.genre) tags.genre = song.genre; + if (song.year) tags.year = String(song.year); + if (song.trackNumber) tags.trackNumber = String(song.trackNumber); + if (song.bpm) tags.bpm = String(Math.round(song.bpm)); + + // Embed cover art if available + if (song.coverArtPath) { + try { + const coverBuffer = await this.storage.download(song.coverArtPath); + const mime = song.coverArtPath.endsWith('.png') ? 'image/png' : 'image/jpeg'; + tags.image = { + mime, + type: { id: 3, name: 'front cover' }, + description: 'Cover', + imageBuffer: coverBuffer, + }; + } catch (e) { + this.logger.warn(`Failed to load cover art for embedding: ${e}`); + } + } + + const taggedBuffer = NodeID3.write(tags, buffer); + if (!taggedBuffer || taggedBuffer instanceof Error) { + throw new BadRequestException('Failed to write ID3 tags'); + } + + await this.storage.upload(song.storagePath, taggedBuffer as Buffer, { + contentType: 'audio/mpeg', + }); + } + + async getCoverDownloadUrl(id: string, userId: string): Promise { + const song = await this.findByIdOrThrow(id, userId); + if (!song.coverArtPath) return null; + return this.storage.getDownloadUrl(song.coverArtPath, { expiresIn: 3600 }); + } + private getSortColumn(field: string) { switch (field) { case 'title': diff --git a/apps/mukke/apps/web/src/lib/components/SongEditor.svelte b/apps/mukke/apps/web/src/lib/components/SongEditor.svelte new file mode 100644 index 000000000..80f56bcef --- /dev/null +++ b/apps/mukke/apps/web/src/lib/components/SongEditor.svelte @@ -0,0 +1,294 @@ + + +{#if open} + + +{/if} diff --git a/apps/mukke/apps/web/src/lib/stores/library.svelte.ts b/apps/mukke/apps/web/src/lib/stores/library.svelte.ts index 774650b85..e200c7fe6 100644 --- a/apps/mukke/apps/web/src/lib/stores/library.svelte.ts +++ b/apps/mukke/apps/web/src/lib/stores/library.svelte.ts @@ -250,6 +250,25 @@ function createLibraryStore() { state.songs = state.songs.map((s) => (s.id === id ? result.song : s)); return result.song; }, + + async extractMetadata(id: string) { + const result = await fetchApi<{ song: Song }>(`/songs/${id}/extract-metadata`, { + method: 'POST', + }); + state.songs = state.songs.map((s) => (s.id === id ? result.song : s)); + return result.song; + }, + + async writeTags(id: string) { + await fetchApi<{ success: boolean }>(`/songs/${id}/write-tags`, { + method: 'POST', + }); + }, + + async getCoverUrl(id: string): Promise { + const result = await fetchApi<{ url: string | null }>(`/songs/${id}/cover-url`); + return result.url; + }, }; } diff --git a/apps/mukke/apps/web/src/routes/(app)/library/+page.svelte b/apps/mukke/apps/web/src/routes/(app)/library/+page.svelte index 9fe7f2249..08116d5ba 100644 --- a/apps/mukke/apps/web/src/routes/(app)/library/+page.svelte +++ b/apps/mukke/apps/web/src/routes/(app)/library/+page.svelte @@ -3,9 +3,13 @@ import { goto } from '$app/navigation'; import { libraryStore } from '$lib/stores/library.svelte'; import { authStore } from '$lib/stores/auth.svelte'; + import SongEditor from '$lib/components/SongEditor.svelte'; + import type { Song } from '@mukke/shared'; const tabs = ['songs', 'albums', 'artists', 'genres'] as const; + let editingSong = $state(null); + function getBackendUrl(): string { let baseUrl = 'http://localhost:3010'; if (typeof window !== 'undefined') { @@ -48,6 +52,12 @@ await libraryStore.toggleFavorite(id); } + function handleEditSong(song: Song, e: Event) { + e.preventDefault(); + e.stopPropagation(); + editingSong = song; + } + async function openInEditor(songId: string, e: Event) { e.preventDefault(); e.stopPropagation(); @@ -126,7 +136,7 @@
Title Artist @@ -134,11 +144,12 @@ Duration +
{#each libraryStore.songs as song}
{song.title} {song.artist ?? 'Unknown'} @@ -146,6 +157,20 @@ {formatDuration(song.duration)} + @@ -293,3 +318,14 @@ {/if} {/if}
+ +{#if editingSong} + { + editingSong = null; + libraryStore.loadSongs(); + }} + /> +{/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 index e2fa400d0..197c3bcae 100644 --- a/apps/mukke/apps/web/src/routes/(app)/upload/+page.svelte +++ b/apps/mukke/apps/web/src/routes/(app)/upload/+page.svelte @@ -86,11 +86,18 @@ files[index].status = 'uploaded'; files[index].progress = 100; files[index].songId = song.id; - if (song.title) files[index].metadata.title = song.title; - if (song.artist) files[index].metadata.artist = song.artist; - if (song.album) files[index].metadata.album = song.album; - if (song.genre) files[index].metadata.genre = song.genre; - if (song.year) files[index].metadata.year = String(song.year); + + // Auto-extract ID3 tags from the uploaded file + try { + const extracted = await libraryStore.extractMetadata(song.id); + if (extracted.title) files[index].metadata.title = extracted.title; + if (extracted.artist) files[index].metadata.artist = extracted.artist; + if (extracted.album) files[index].metadata.album = extracted.album ?? ''; + if (extracted.genre) files[index].metadata.genre = extracted.genre ?? ''; + if (extracted.year) files[index].metadata.year = String(extracted.year); + } catch { + // Non-fatal: user can still edit metadata manually + } } catch (e) { files[index].status = 'error'; files[index].error = e instanceof Error ? e.message : 'Upload failed'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0938b2a2..dac77b0e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2777,6 +2777,9 @@ importers: music-metadata: specifier: ^11.12.3 version: 11.12.3 + node-id3: + specifier: ^0.2.9 + version: 0.2.9 postgres: specifier: ^3.4.5 version: 3.4.7 @@ -2789,7 +2792,7 @@ importers: devDependencies: '@nestjs/cli': specifier: ^10.4.9 - version: 10.4.9(esbuild@0.27.0) + version: 10.4.9(esbuild@0.19.12) '@nestjs/schematics': specifier: ^10.2.3 version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) @@ -2822,7 +2825,7 @@ importers: version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2) jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + version: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) prettier: specifier: ^3.4.2 version: 3.6.2 @@ -2831,10 +2834,10 @@ importers: version: 0.5.21 ts-jest: specifier: ^29.4.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.3.0)(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.3.0)(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.1 - version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)) + version: 9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -3559,7 +3562,7 @@ importers: devDependencies: '@nestjs/cli': specifier: ^10.4.9 - version: 10.4.9(esbuild@0.19.12) + version: 10.4.9(esbuild@0.27.0) '@nestjs/schematics': specifier: ^10.2.3 version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3) @@ -3595,7 +3598,7 @@ importers: version: 5.5.4(@types/eslint@9.6.1)(eslint-config-prettier@9.1.2(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1))(prettier@3.6.2) jest: specifier: ^30.2.0 - version: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) + version: 30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)) prettier: specifier: ^3.4.2 version: 3.6.2 @@ -3604,10 +3607,10 @@ importers: version: 0.5.21 ts-jest: specifier: ^29.2.5 - version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.19.12)(jest-util@30.3.0)(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.19.12))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.28.5))(esbuild@0.27.0)(jest-util@30.3.0)(jest@30.3.0(@types/node@22.19.1)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@types/node@22.19.1)(typescript@5.9.3)))(typescript@5.9.3) ts-loader: specifier: ^9.5.1 - version: 9.5.4(typescript@5.9.3)(webpack@5.97.1(esbuild@0.19.12)) + version: 9.5.4(typescript@5.9.3)(webpack@5.100.2(esbuild@0.27.0)) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.1)(typescript@5.9.3) @@ -20154,6 +20157,10 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} + iconv-lite@0.6.2: + resolution: {integrity: sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==} + engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -22439,6 +22446,9 @@ packages: engines: {node: ^12.13 || ^14.13 || >=16} hasBin: true + node-id3@0.2.9: + resolution: {integrity: sha512-dSxhuxrkkGVRgUhDHFxdY0pilzOREcodO01HcZWfaRkCaPWGmo0dOgD8ygyL6ln4Iv4cmfRxAWn1WD9bIB9Bhw==} + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -48148,6 +48158,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.6.2: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -52237,6 +52251,10 @@ snapshots: - bluebird - supports-color + node-id3@0.2.9: + dependencies: + iconv-lite: 0.6.2 + node-int64@0.4.0: {} node-mock-http@1.0.3: {}