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([]); const [enhancedAlbums, setEnhancedAlbums] = useState([]); const { toast } = useToast(); const api = getNavidromeAPI(); /** * Find enhanced metadata for a single track from MusicBrainz */ const enhanceTrack = useCallback(async (track: Song): Promise => { 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 => { 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> = {}; 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]; }