- 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.
604 lines
20 KiB
TypeScript
604 lines
20 KiB
TypeScript
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];
|
|
}
|