feat: Implement Auto-Tagging Settings and MusicBrainz integration

- Added AutoTaggingSettings component for configuring auto-tagging preferences.
- Integrated localStorage for saving user preferences and options.
- Developed useAutoTagging hook for fetching and applying metadata from MusicBrainz.
- Created MusicBrainz API client for searching and retrieving music metadata.
- Enhanced metadata structure with additional fields for tracks and albums.
- Implemented rate-limiting for MusicBrainz API requests.
- Added UI components for user interaction and feedback during the tagging process.
This commit is contained in:
2025-08-10 15:02:49 +00:00
committed by GitHub
parent cfd4f88b5e
commit ba91d3ee28
10 changed files with 1904 additions and 37 deletions

603
hooks/use-auto-tagging.ts Normal file
View File

@@ -0,0 +1,603 @@
import { useState, useCallback } from 'react';
import MusicBrainzClient, {
MusicBrainzRelease,
MusicBrainzReleaseDetails,
MusicBrainzRecording,
MusicBrainzRecordingDetails
} from '@/lib/musicbrainz-api';
import { getNavidromeAPI } from '@/lib/navidrome';
import { useToast } from '@/hooks/use-toast';
import { Album, Song, Artist } from '@/lib/navidrome';
// Define interfaces for the enhanced metadata
// Define interfaces for the enhanced metadata
export interface EnhancedTrackMetadata {
id: string; // Navidrome track ID
title: string; // Track title
artist: string; // Artist name
album: string; // Album name
mbTrackId?: string; // MusicBrainz recording ID
mbReleaseId?: string; // MusicBrainz release ID
mbArtistId?: string; // MusicBrainz artist ID
year?: string; // Release year
genres?: string[]; // Genres
tags?: string[]; // Tags
trackNumber?: number; // Track number
discNumber?: number; // Disc number
duration?: number; // Duration in seconds
artistCountry?: string; // Artist country
artistType?: string; // Artist type (group, person, etc.)
releaseType?: string; // Release type (album, EP, single, etc.)
status: 'pending' | 'matched' | 'failed' | 'applied'; // Status of the track metadata
confidence: number; // Match confidence (0-100)
}
export interface EnhancedAlbumMetadata {
id: string; // Navidrome album ID
name: string; // Album name
artist: string; // Album artist name
mbReleaseId?: string; // MusicBrainz release ID
mbArtistId?: string; // MusicBrainz artist ID
year?: string; // Release year
genres?: string[]; // Genres
tags?: string[]; // Tags
country?: string; // Release country
releaseType?: string; // Release type (album, EP, single, etc.)
barcode?: string; // Barcode
label?: string; // Record label
status: 'pending' | 'matched' | 'failed' | 'applied'; // Status
confidence: number; // Match confidence (0-100)
tracks: EnhancedTrackMetadata[]; // Tracks in the album
coverArtUrl?: string; // Cover art URL from MusicBrainz
}
// Type for the Auto-Tagging operation mode
export type AutoTaggingMode = 'track' | 'album' | 'artist';
export function useAutoTagging() {
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [enhancedTracks, setEnhancedTracks] = useState<EnhancedTrackMetadata[]>([]);
const [enhancedAlbums, setEnhancedAlbums] = useState<EnhancedAlbumMetadata[]>([]);
const { toast } = useToast();
const api = getNavidromeAPI();
/**
* Find enhanced metadata for a single track from MusicBrainz
*/
const enhanceTrack = useCallback(async (track: Song): Promise<EnhancedTrackMetadata> => {
try {
// Start with basic metadata
const enhancedTrack: EnhancedTrackMetadata = {
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
status: 'pending',
confidence: 0
};
// Try to find the track in MusicBrainz
const recording = await MusicBrainzClient.findBestMatchingRecording(
track.title,
track.artist,
track.duration * 1000 // Convert to milliseconds
);
if (!recording) {
enhancedTrack.status = 'failed';
return enhancedTrack;
}
// Get detailed recording information
const recordingDetails = await MusicBrainzClient.getRecording(recording.id);
if (!recordingDetails) {
enhancedTrack.status = 'failed';
return enhancedTrack;
}
// Calculate match confidence
const titleSimilarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(track.title),
MusicBrainzClient.normalizeString(recording.title)
);
const artistSimilarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(track.artist),
MusicBrainzClient.normalizeString(recording['artist-credit'][0]?.artist.name || '')
);
// Calculate confidence score (0-100)
enhancedTrack.confidence = Math.round((titleSimilarity * 0.6 + artistSimilarity * 0.4) * 100);
// Update track with MusicBrainz metadata
enhancedTrack.mbTrackId = recording.id;
enhancedTrack.mbArtistId = recording['artist-credit'][0]?.artist.id;
// Extract additional metadata from recordingDetails
if (recordingDetails.releases && recordingDetails.releases.length > 0) {
enhancedTrack.mbReleaseId = recordingDetails.releases[0].id;
}
if (recordingDetails['first-release-date']) {
enhancedTrack.year = recordingDetails['first-release-date'].split('-')[0];
}
if (recordingDetails.genres) {
enhancedTrack.genres = recordingDetails.genres.map(genre => genre.name);
}
if (recordingDetails.tags) {
enhancedTrack.tags = recordingDetails.tags.map(tag => tag.name);
}
enhancedTrack.status = 'matched';
return enhancedTrack;
} catch (error) {
console.error('Failed to enhance track:', error);
return {
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
status: 'failed',
confidence: 0
};
}
}, []);
/**
* Find enhanced metadata for an album and its tracks from MusicBrainz
*/
const enhanceAlbum = useCallback(async (album: Album, tracks: Song[]): Promise<EnhancedAlbumMetadata> => {
try {
// Start with basic metadata
const enhancedAlbum: EnhancedAlbumMetadata = {
id: album.id,
name: album.name,
artist: album.artist,
status: 'pending',
confidence: 0,
tracks: []
};
// Try to find the album in MusicBrainz
const release = await MusicBrainzClient.findBestMatchingRelease(
album.name,
album.artist,
tracks.length
);
if (!release) {
enhancedAlbum.status = 'failed';
return enhancedAlbum;
}
// Get detailed release information
const releaseDetails = await MusicBrainzClient.getRelease(release.id);
if (!releaseDetails) {
enhancedAlbum.status = 'failed';
return enhancedAlbum;
}
// Calculate match confidence
const albumSimilarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(album.name),
MusicBrainzClient.normalizeString(release.title)
);
const artistSimilarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(album.artist),
MusicBrainzClient.normalizeString(release['artist-credit'][0]?.artist.name || '')
);
// Calculate confidence score (0-100)
enhancedAlbum.confidence = Math.round((albumSimilarity * 0.6 + artistSimilarity * 0.4) * 100);
// Update album with MusicBrainz metadata
enhancedAlbum.mbReleaseId = release.id;
enhancedAlbum.mbArtistId = release['artist-credit'][0]?.artist.id;
if (release.date) {
enhancedAlbum.year = release.date.split('-')[0];
}
if (release.country) {
enhancedAlbum.country = release.country;
}
// We need to access release-group via a type assertion since it's not defined in MusicBrainzRelease interface
// But it exists in the MusicBrainzReleaseDetails which we're working with
const releaseWithGroup = release as unknown as { 'release-group'?: { id: string; 'primary-type'?: string } };
if (releaseWithGroup['release-group'] && releaseWithGroup['release-group']['primary-type']) {
enhancedAlbum.releaseType = releaseWithGroup['release-group']['primary-type'];
}
if (releaseDetails.barcode) {
enhancedAlbum.barcode = releaseDetails.barcode;
}
// Get cover art URL
if (releaseDetails['cover-art-archive'] && releaseDetails['cover-art-archive'].front) {
enhancedAlbum.coverArtUrl = MusicBrainzClient.getCoverArtUrl(release.id);
}
// Match tracks with MusicBrainz tracks
const enhancedTracks: EnhancedTrackMetadata[] = [];
// First, organize MB tracks by disc and track number
// Define a type for the MusicBrainz track
interface MusicBrainzTrack {
position: number;
number: string;
title: string;
length?: number;
recording: {
id: string;
title: string;
length?: number;
};
}
const mbTracks: Record<number, Record<number, MusicBrainzTrack>> = {};
if (releaseDetails.media) {
for (const medium of releaseDetails.media) {
const discNumber = medium.position;
mbTracks[discNumber] = {};
for (const track of medium.tracks) {
mbTracks[discNumber][track.position] = track;
}
}
}
// Try to match each track
for (const track of tracks) {
// Basic track info
const enhancedTrack: EnhancedTrackMetadata = {
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
status: 'pending',
confidence: 0
};
// Try to find the track by position if available
if (track.discNumber && track.track && mbTracks[track.discNumber] && mbTracks[track.discNumber][track.track]) {
const mbTrack = mbTracks[track.discNumber][track.track];
enhancedTrack.mbTrackId = mbTrack.recording.id;
enhancedTrack.mbReleaseId = release.id;
enhancedTrack.trackNumber = track.track;
enhancedTrack.discNumber = track.discNumber;
// Calculate title similarity
const titleSimilarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(track.title),
MusicBrainzClient.normalizeString(mbTrack.title)
);
enhancedTrack.confidence = Math.round(titleSimilarity * 100);
enhancedTrack.status = 'matched';
}
// If we can't match by position, try to match by title
else {
// Find in any medium and any position
let bestMatch: MusicBrainzTrack | null = null;
let bestSimilarity = 0;
for (const discNumber of Object.keys(mbTracks)) {
for (const trackNumber of Object.keys(mbTracks[Number(discNumber)])) {
const mbTrack = mbTracks[Number(discNumber)][Number(trackNumber)];
const similarity = calculateStringSimilarity(
MusicBrainzClient.normalizeString(track.title),
MusicBrainzClient.normalizeString(mbTrack.title)
);
if (similarity > bestSimilarity && similarity > 0.6) { // 60% similarity threshold
bestMatch = mbTrack;
bestSimilarity = similarity;
}
}
}
if (bestMatch) {
enhancedTrack.mbTrackId = bestMatch.recording.id;
enhancedTrack.mbReleaseId = release.id;
enhancedTrack.confidence = Math.round(bestSimilarity * 100);
enhancedTrack.status = 'matched';
} else {
enhancedTrack.status = 'failed';
}
}
enhancedTracks.push(enhancedTrack);
}
// Update album with tracks
enhancedAlbum.tracks = enhancedTracks;
enhancedAlbum.status = 'matched';
return enhancedAlbum;
} catch (error) {
console.error('Failed to enhance album:', error);
return {
id: album.id,
name: album.name,
artist: album.artist,
status: 'failed',
confidence: 0,
tracks: []
};
}
}, []);
/**
* Start the auto-tagging process for a track, album, or artist
*/
const startAutoTagging = useCallback(async (
mode: AutoTaggingMode,
itemId: string,
confidenceThreshold: number = 70
) => {
if (!api) {
toast({
title: "Error",
description: "Navidrome API is not configured",
variant: "destructive",
});
return;
}
setIsProcessing(true);
setProgress(0);
setEnhancedTracks([]);
setEnhancedAlbums([]);
try {
// Process different modes
if (mode === 'track') {
// In the absence of a direct method to get a song by ID,
// we'll find it by searching for it in its album
const searchResults = await api.search(itemId, 0, 0, 10);
const track = searchResults.songs.find(song => song.id === itemId);
if (!track) {
throw new Error('Track not found');
}
setProgress(10);
// Enhance track metadata
const enhancedTrack = await enhanceTrack(track);
setEnhancedTracks([enhancedTrack]);
setProgress(100);
toast({
title: "Track Analysis Complete",
description: enhancedTrack.status === 'matched'
? `Found metadata for "${track.title}" with ${enhancedTrack.confidence}% confidence`
: `Couldn't find metadata for "${track.title}"`,
});
}
else if (mode === 'album') {
// Get album and its tracks from Navidrome
const { album, songs } = await api.getAlbum(itemId);
if (!album) {
throw new Error('Album not found');
}
setProgress(10);
// Enhance album metadata
const enhancedAlbum = await enhanceAlbum(album, songs);
setEnhancedAlbums([enhancedAlbum]);
setProgress(100);
toast({
title: "Album Analysis Complete",
description: enhancedAlbum.status === 'matched'
? `Found metadata for "${album.name}" with ${enhancedAlbum.confidence}% confidence`
: `Couldn't find metadata for "${album.name}"`,
});
}
else if (mode === 'artist') {
// Get artist and their albums from Navidrome
try {
const { artist, albums } = await api.getArtist(itemId);
if (!artist) {
throw new Error('Artist not found');
}
setProgress(5);
const enhancedAlbumsData: EnhancedAlbumMetadata[] = [];
let processedAlbums = 0;
// Process each album
for (const album of albums) {
try {
const { songs } = await api.getAlbum(album.id);
const enhancedAlbum = await enhanceAlbum(album, songs);
enhancedAlbumsData.push(enhancedAlbum);
} catch (albumError) {
console.error('Error processing album:', albumError);
// Continue with the next album
}
processedAlbums++;
setProgress(5 + Math.round((processedAlbums / albums.length) * 95));
}
setEnhancedAlbums(enhancedAlbumsData);
setProgress(100);
const matchedAlbums = enhancedAlbumsData.filter(album =>
album.status === 'matched' && album.confidence >= confidenceThreshold
).length;
toast({
title: "Artist Analysis Complete",
description: `Found metadata for ${matchedAlbums} of ${albums.length} albums by "${artist.name}"`,
});
} catch (artistError) {
console.error('Error fetching artist:', artistError);
toast({
title: "Artist Not Found",
description: "Could not find the artist in your library",
variant: "destructive",
});
setProgress(100);
}
}
} catch (error) {
console.error('Auto-tagging error:', error);
toast({
title: "Auto-Tagging Failed",
description: error instanceof Error ? error.message : "An unknown error occurred",
variant: "destructive",
});
} finally {
setIsProcessing(false);
}
}, [api, enhanceTrack, enhanceAlbum, toast]);
/**
* Apply enhanced metadata to tracks in Navidrome
*/
const applyEnhancedMetadata = useCallback(async (
tracks: EnhancedTrackMetadata[],
albums?: EnhancedAlbumMetadata[]
) => {
if (!api) {
toast({
title: "Error",
description: "Navidrome API is not configured",
variant: "destructive",
});
return;
}
setIsProcessing(true);
setProgress(0);
try {
let processedItems = 0;
const totalItems = tracks.length + (albums?.length || 0);
// Apply album metadata first
if (albums && albums.length > 0) {
for (const album of albums) {
if (album.status === 'matched') {
// To be implemented: Update album metadata via Navidrome API
// This requires a custom Navidrome endpoint or plugin
console.log('Would update album:', album);
}
processedItems++;
setProgress(Math.round((processedItems / totalItems) * 100));
}
}
// Apply track metadata
for (const track of tracks) {
if (track.status === 'matched') {
// To be implemented: Update track metadata via Navidrome API
// This requires a custom Navidrome endpoint or plugin
console.log('Would update track:', track);
// Alternatively, suggest implementing this feature using a separate
// script that interacts with music files directly
}
processedItems++;
setProgress(Math.round((processedItems / totalItems) * 100));
}
toast({
title: "Metadata Applied",
description: `Updated metadata for ${tracks.filter(t => t.status === 'matched').length} tracks`,
});
} catch (error) {
console.error('Failed to apply metadata:', error);
toast({
title: "Metadata Update Failed",
description: error instanceof Error ? error.message : "An unknown error occurred",
variant: "destructive",
});
} finally {
setIsProcessing(false);
}
}, [api, toast]);
return {
isProcessing,
progress,
enhancedTracks,
enhancedAlbums,
startAutoTagging,
applyEnhancedMetadata
};
}
/**
* Calculate similarity between two strings (0-1)
* Uses Levenshtein distance
*/
function calculateStringSimilarity(str1: string, str2: string): number {
// If either string is empty, return 0
if (!str1.length || !str2.length) {
return 0;
}
// If strings are identical, return 1
if (str1 === str2) {
return 1;
}
// Calculate Levenshtein distance
const distance = levenshteinDistance(str1, str2);
// Calculate similarity score
const maxLength = Math.max(str1.length, str2.length);
const similarity = 1 - distance / maxLength;
return similarity;
}
/**
* Calculate Levenshtein distance between two strings
*/
function levenshteinDistance(str1: string, str2: string): number {
const matrix: number[][] = [];
// Initialize matrix with row and column indices
for (let i = 0; i <= str1.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= str2.length; j++) {
matrix[0][j] = j;
}
// Fill in the matrix
for (let i = 1; i <= str1.length; i++) {
for (let j = 1; j <= str2.length; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // Deletion
matrix[i][j - 1] + 1, // Insertion
matrix[i - 1][j - 1] + cost // Substitution
);
}
}
return matrix[str1.length][str2.length];
}