mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 10:39:40 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
b11e1284dc
commit
34c6b9c973
8 changed files with 512 additions and 16 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Song> {
|
||||
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<string, unknown> = {};
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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':
|
||||
|
|
|
|||
294
apps/mukke/apps/web/src/lib/components/SongEditor.svelte
Normal file
294
apps/mukke/apps/web/src/lib/components/SongEditor.svelte
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
<script lang="ts">
|
||||
import type { Song } from '@mukke/shared';
|
||||
import { libraryStore } from '$lib/stores/library.svelte';
|
||||
|
||||
interface Props {
|
||||
song: Song;
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { song, open, onclose }: Props = $props();
|
||||
|
||||
let title = $state('');
|
||||
let artist = $state('');
|
||||
let album = $state('');
|
||||
let albumArtist = $state('');
|
||||
let genre = $state('');
|
||||
let trackNumber = $state('');
|
||||
let year = $state('');
|
||||
let bpm = $state('');
|
||||
let coverUrl = $state<string | null>(null);
|
||||
let saving = $state(false);
|
||||
let writingTags = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let success = $state<string | null>(null);
|
||||
|
||||
const isMp3 = $derived(song.storagePath.toLowerCase().endsWith('.mp3'));
|
||||
|
||||
$effect(() => {
|
||||
if (open && song) {
|
||||
title = song.title ?? '';
|
||||
artist = song.artist ?? '';
|
||||
album = song.album ?? '';
|
||||
albumArtist = song.albumArtist ?? '';
|
||||
genre = song.genre ?? '';
|
||||
trackNumber = song.trackNumber ? String(song.trackNumber) : '';
|
||||
year = song.year ? String(song.year) : '';
|
||||
bpm = song.bpm ? String(song.bpm) : '';
|
||||
error = null;
|
||||
success = null;
|
||||
loadCoverUrl();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadCoverUrl() {
|
||||
coverUrl = null;
|
||||
if (song.coverArtPath) {
|
||||
try {
|
||||
coverUrl = await libraryStore.getCoverUrl(song.id);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
error = null;
|
||||
success = null;
|
||||
try {
|
||||
await libraryStore.updateSongMetadata(song.id, {
|
||||
title: title || undefined,
|
||||
artist: artist || undefined,
|
||||
album: album || undefined,
|
||||
albumArtist: albumArtist || undefined,
|
||||
genre: genre || undefined,
|
||||
trackNumber: trackNumber ? parseInt(trackNumber) : undefined,
|
||||
year: year ? parseInt(year) : undefined,
|
||||
bpm: bpm ? parseFloat(bpm) : undefined,
|
||||
});
|
||||
success = 'Metadata saved';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to save';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWriteTags() {
|
||||
writingTags = true;
|
||||
error = null;
|
||||
success = null;
|
||||
try {
|
||||
await libraryStore.writeTags(song.id);
|
||||
success = 'Tags written to file';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to write tags';
|
||||
} finally {
|
||||
writingTags = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onclose();
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Edit song metadata"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<div class="bg-surface rounded-xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-border">
|
||||
<h2 class="text-lg font-semibold">Edit Song</h2>
|
||||
<button
|
||||
onclick={onclose}
|
||||
class="p-1 text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- Cover Art -->
|
||||
{#if coverUrl}
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt="Cover art"
|
||||
class="w-32 h-32 object-cover rounded-lg shadow-md"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Form Fields -->
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label for="edit-title" class="block text-xs font-medium text-foreground-secondary mb-1"
|
||||
>Title</label
|
||||
>
|
||||
<input
|
||||
id="edit-title"
|
||||
type="text"
|
||||
bind:value={title}
|
||||
class="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label
|
||||
for="edit-artist"
|
||||
class="block text-xs font-medium text-foreground-secondary mb-1">Artist</label
|
||||
>
|
||||
<input
|
||||
id="edit-artist"
|
||||
type="text"
|
||||
bind:value={artist}
|
||||
class="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
|
||||
placeholder="Artist"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="edit-album"
|
||||
class="block text-xs font-medium text-foreground-secondary mb-1">Album</label
|
||||
>
|
||||
<input
|
||||
id="edit-album"
|
||||
type="text"
|
||||
bind:value={album}
|
||||
class="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
|
||||
placeholder="Album"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="edit-album-artist"
|
||||
class="block text-xs font-medium text-foreground-secondary mb-1">Album Artist</label
|
||||
>
|
||||
<input
|
||||
id="edit-album-artist"
|
||||
type="text"
|
||||
bind:value={albumArtist}
|
||||
class="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
|
||||
placeholder="Album Artist"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label
|
||||
for="edit-genre"
|
||||
class="block text-xs font-medium text-foreground-secondary mb-1">Genre</label
|
||||
>
|
||||
<input
|
||||
id="edit-genre"
|
||||
type="text"
|
||||
bind:value={genre}
|
||||
class="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
|
||||
placeholder="Genre"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="edit-year"
|
||||
class="block text-xs font-medium text-foreground-secondary mb-1">Year</label
|
||||
>
|
||||
<input
|
||||
id="edit-year"
|
||||
type="text"
|
||||
bind:value={year}
|
||||
class="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
|
||||
placeholder="Year"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label
|
||||
for="edit-track"
|
||||
class="block text-xs font-medium text-foreground-secondary mb-1">Track #</label
|
||||
>
|
||||
<input
|
||||
id="edit-track"
|
||||
type="text"
|
||||
bind:value={trackNumber}
|
||||
class="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
|
||||
placeholder="1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="edit-bpm" class="block text-xs font-medium text-foreground-secondary mb-1"
|
||||
>BPM</label
|
||||
>
|
||||
<input
|
||||
id="edit-bpm"
|
||||
type="text"
|
||||
bind:value={bpm}
|
||||
class="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:border-primary focus:outline-none"
|
||||
placeholder="120"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
{#if error}
|
||||
<p class="text-sm text-red-500">{error}</p>
|
||||
{/if}
|
||||
{#if success}
|
||||
<p class="text-sm text-green-500">{success}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between pt-2 border-t border-border">
|
||||
{#if isMp3}
|
||||
<button
|
||||
onclick={handleWriteTags}
|
||||
disabled={writingTags || saving}
|
||||
class="px-3 py-2 text-sm border border-border rounded-lg hover:bg-background disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{writingTags ? 'Writing...' : 'Write to File'}
|
||||
</button>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={onclose}
|
||||
class="px-3 py-2 text-sm text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={saving || writingTags}
|
||||
class="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary-hover disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -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<string | null> {
|
||||
const result = await fetchApi<{ url: string | null }>(`/songs/${id}/cover-url`);
|
||||
return result.url;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Song | null>(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 @@
|
|||
<div class="bg-surface rounded-lg overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="grid grid-cols-[1fr_1fr_1fr_80px_40px_40px] gap-4 px-4 py-3 text-xs font-medium text-foreground-secondary uppercase tracking-wide border-b border-border"
|
||||
class="grid grid-cols-[1fr_1fr_1fr_80px_40px_40px_40px] gap-4 px-4 py-3 text-xs font-medium text-foreground-secondary uppercase tracking-wide border-b border-border"
|
||||
>
|
||||
<span>Title</span>
|
||||
<span>Artist</span>
|
||||
|
|
@ -134,11 +144,12 @@
|
|||
<span class="text-right">Duration</span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<!-- Song rows -->
|
||||
{#each libraryStore.songs as song}
|
||||
<div
|
||||
class="grid grid-cols-[1fr_1fr_1fr_80px_40px_40px] gap-4 px-4 py-3 hover:bg-background transition-colors items-center"
|
||||
class="grid grid-cols-[1fr_1fr_1fr_80px_40px_40px_40px] gap-4 px-4 py-3 hover:bg-background transition-colors items-center"
|
||||
>
|
||||
<span class="truncate font-medium">{song.title}</span>
|
||||
<span class="truncate text-foreground-secondary">{song.artist ?? 'Unknown'}</span>
|
||||
|
|
@ -146,6 +157,20 @@
|
|||
<span class="text-right text-foreground-secondary text-sm">
|
||||
{formatDuration(song.duration)}
|
||||
</span>
|
||||
<button
|
||||
onclick={(e) => handleEditSong(song, e)}
|
||||
class="p-1 text-foreground-secondary hover:text-primary transition-colors"
|
||||
title="Edit metadata"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={(e) => openInEditor(song.id, e)}
|
||||
class="p-1 text-foreground-secondary hover:text-primary transition-colors"
|
||||
|
|
@ -156,7 +181,7 @@
|
|||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
d="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"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
|
@ -293,3 +318,14 @@
|
|||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if editingSong}
|
||||
<SongEditor
|
||||
song={editingSong}
|
||||
open={editingSong !== null}
|
||||
onclose={() => {
|
||||
editingSong = null;
|
||||
libraryStore.loadSongs();
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue