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:
Till JS 2026-03-19 14:41:18 +01:00
parent b11e1284dc
commit 34c6b9c973
8 changed files with 512 additions and 16 deletions

View file

@ -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"

View file

@ -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,

View file

@ -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':

View 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}

View file

@ -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;
},
};
}

View file

@ -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}

View file

@ -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
View file

@ -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: {}