From ba91d3ee28af85eb51da60549d2160bbc15cb5e2 Mon Sep 17 00:00:00 2001 From: angel Date: Sun, 10 Aug 2025 15:02:49 +0000 Subject: [PATCH] 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. --- app/components/AutoTagContextMenu.tsx | 73 +++ app/components/AutoTaggingDialog.tsx | 319 +++++++++++++ app/components/AutoTaggingSettings.tsx | 221 +++++++++ app/components/DraggableMiniPlayer.tsx | 262 +++++++++-- app/settings/page.tsx | 10 +- hooks/use-auto-tagging.ts | 603 +++++++++++++++++++++++++ lib/image-utils.ts | 82 ++++ lib/musicbrainz-api.ts | 347 ++++++++++++++ lib/navidrome.ts | 21 +- next.config.mjs | 3 + 10 files changed, 1904 insertions(+), 37 deletions(-) create mode 100644 app/components/AutoTagContextMenu.tsx create mode 100644 app/components/AutoTaggingDialog.tsx create mode 100644 app/components/AutoTaggingSettings.tsx create mode 100644 hooks/use-auto-tagging.ts create mode 100644 lib/musicbrainz-api.ts diff --git a/app/components/AutoTagContextMenu.tsx b/app/components/AutoTagContextMenu.tsx new file mode 100644 index 0000000..c8e190a --- /dev/null +++ b/app/components/AutoTagContextMenu.tsx @@ -0,0 +1,73 @@ +'use client'; + +import React, { useState } from 'react'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { MusicIcon, TagIcon, InfoIcon } from 'lucide-react'; +import { AutoTaggingDialog } from './AutoTaggingDialog'; + +interface AutoTagContextMenuProps { + children: React.ReactNode; + mode: 'track' | 'album' | 'artist'; + itemId: string; + itemName: string; + artistName?: string; +} + +export function AutoTagContextMenu({ + children, + mode, + itemId, + itemName, + artistName +}: AutoTagContextMenuProps) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + + return ( + <> + + + {children} + + + setIsDialogOpen(true)} + className="cursor-pointer" + > + + Auto-Tag {mode === 'track' ? 'Track' : mode === 'album' ? 'Album' : 'Artist'} + + {mode === 'track' && ( + <> + + + + View Track Details + + + + Edit Track Metadata + + + )} + + + + setIsDialogOpen(false)} + mode={mode} + itemId={itemId} + itemName={itemName} + artistName={artistName} + /> + + ); +} + +export default AutoTagContextMenu; diff --git a/app/components/AutoTaggingDialog.tsx b/app/components/AutoTaggingDialog.tsx new file mode 100644 index 0000000..267c2e2 --- /dev/null +++ b/app/components/AutoTaggingDialog.tsx @@ -0,0 +1,319 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { useToast } from "@/hooks/use-toast"; +import { useAutoTagging, EnhancedTrackMetadata, EnhancedAlbumMetadata } from "@/hooks/use-auto-tagging"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { + MusicIcon, + AlbumIcon, + UsersIcon, + CheckCircle2Icon, + XCircleIcon, + AlertTriangleIcon, + InfoIcon +} from 'lucide-react'; +import Image from 'next/image'; + +interface AutoTaggingDialogProps { + isOpen: boolean; + onClose: () => void; + mode: 'track' | 'album' | 'artist'; + itemId: string; + itemName: string; + artistName?: string; +} + +export const AutoTaggingDialog: React.FC = ({ + isOpen, + onClose, + mode, + itemId, + itemName, + artistName +}) => { + const isMobile = useIsMobile(); + const { toast } = useToast(); + const [confidenceThreshold, setConfidenceThreshold] = useState(70); + const [activeTab, setActiveTab] = useState<'tracks' | 'albums'>('tracks'); + const [isApplying, setIsApplying] = useState(false); + const { + isProcessing, + progress, + enhancedTracks, + enhancedAlbums, + startAutoTagging, + applyEnhancedMetadata + } = useAutoTagging(); + + // Start auto-tagging when the dialog is opened + useEffect(() => { + if (isOpen && itemId && !isProcessing && progress === 0) { + // Wrap in try/catch to handle any errors that might occur during auto-tagging + try { + startAutoTagging(mode, itemId, confidenceThreshold); + } catch (error) { + console.error('Failed to start auto-tagging:', error); + toast({ + title: "Auto-Tagging Error", + description: error instanceof Error ? error.message : "Failed to start auto-tagging", + variant: "destructive", + }); + onClose(); + } + } + }, [isOpen, itemId, mode, isProcessing, progress, startAutoTagging, confidenceThreshold, toast, onClose]); + + // Set the active tab based on the mode + useEffect(() => { + if (mode === 'track') { + setActiveTab('tracks'); + } else if (mode === 'album' || mode === 'artist') { + setActiveTab('albums'); + } + }, [mode]); + + const handleApplyMetadata = async () => { + try { + setIsApplying(true); + await applyEnhancedMetadata( + enhancedTracks.filter(track => track.status === 'matched' && track.confidence >= confidenceThreshold), + enhancedAlbums.filter(album => album.status === 'matched' && album.confidence >= confidenceThreshold) + ); + onClose(); + } catch (error) { + console.error('Failed to apply metadata:', error); + toast({ + title: "Error", + description: "Failed to apply metadata", + variant: "destructive", + }); + } finally { + setIsApplying(false); + } + }; + + // Get match statistics + const matchedTracks = enhancedTracks.filter(track => track.status === 'matched' && track.confidence >= confidenceThreshold).length; + const totalTracks = enhancedTracks.length; + const matchedAlbums = enhancedAlbums.filter(album => album.status === 'matched' && album.confidence >= confidenceThreshold).length; + const totalAlbums = enhancedAlbums.length; + + const getStatusIcon = (status: 'pending' | 'matched' | 'failed' | 'applied', confidence: number) => { + if (status === 'pending') return ; + if (status === 'failed') return ; + if (status === 'matched' && confidence >= confidenceThreshold) return ; + if (status === 'matched' && confidence < confidenceThreshold) return ; + if (status === 'applied') return ; + return null; + }; + + const getConfidenceColor = (confidence: number) => { + if (confidence >= 90) return 'bg-green-500'; + if (confidence >= 70) return 'bg-green-400'; + if (confidence >= 50) return 'bg-yellow-500'; + return 'bg-red-500'; + }; + + // Render the appropriate dialog/sheet based on mobile status + const DialogComponent = isMobile ? Sheet : Dialog; + const DialogContentComponent = isMobile ? SheetContent : DialogContent; + const DialogHeaderComponent = isMobile ? SheetHeader : DialogHeader; + const DialogTitleComponent = isMobile ? SheetTitle : DialogTitle; + const DialogDescriptionComponent = isMobile ? SheetDescription : DialogDescription; + + return ( + !open && onClose()}> + + + + Auto-Tagging {mode === 'track' ? 'Track' : mode === 'album' ? 'Album' : 'Artist'} + + + {isProcessing ? ( + `Analyzing ${mode === 'track' ? 'track' : mode === 'album' ? 'album' : 'artist'} "${itemName}"` + ) : ( + `Found metadata for ${matchedTracks} of ${totalTracks} tracks${totalAlbums > 0 ? ` and ${matchedAlbums} of ${totalAlbums} albums` : ''}` + )} + + + {/* Progress bar */} + {(isProcessing || isApplying) && ( +
+ +

+ {isProcessing ? 'Analyzing metadata...' : 'Applying metadata...'} +

+
+ )} +
+ + {/* Tabs for tracks and albums */} + {!isProcessing && !isApplying && ( +
+ setActiveTab(value as 'tracks' | 'albums')} className="flex-1 flex flex-col"> +
+ + + Tracks ({matchedTracks}/{totalTracks}) + + + Albums ({matchedAlbums}/{totalAlbums}) + + + + {/* Confidence threshold slider */} +
+ Min. Confidence: {confidenceThreshold}% + setConfidenceThreshold(parseInt(e.target.value))} + className="w-24" + /> +
+
+ + {/* Tracks tab content */} + +
+
+
+
Title
+
Artist
+
Album
+
Confidence
+
+
+ {enhancedTracks.map(track => ( +
+
+ {getStatusIcon(track.status, track.confidence)} +
+
+ {track.title} +
+
+ {track.artist} +
+
+ {track.album} +
+
+
+
+
+ {track.confidence}% +
+
+ ))} +
+
+ + + {/* Albums tab content */} + +
+ {enhancedAlbums.map(album => ( +
+
+ {/* Album cover */} +
+ {album.coverArtUrl ? ( + {album.name} + ) : ( +
+ +
+ )} + {/* Status badge */} +
+ {getStatusIcon(album.status, album.confidence)} +
+
+ {/* Album info */} +
+

{album.name}

+

{album.artist}

+
+
+
+
+ {album.confidence}% +
+ {album.year && ( +

Year: {album.year}

+ )} +
+
+
+ ))} +
+ + +
+ )} + + +
+ + +
+
+ + + ); +}; + +export default AutoTaggingDialog; diff --git a/app/components/AutoTaggingSettings.tsx b/app/components/AutoTaggingSettings.tsx new file mode 100644 index 0000000..86467b7 --- /dev/null +++ b/app/components/AutoTaggingSettings.tsx @@ -0,0 +1,221 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { FaTags } from 'react-icons/fa'; +import { useToast } from '@/hooks/use-toast'; +import { AutoTaggingDialog } from './AutoTaggingDialog'; + +export const AutoTaggingSettings = () => { + const { toast } = useToast(); + const [isClient, setIsClient] = useState(false); + const [autoTaggingEnabled, setAutoTaggingEnabled] = useState(false); + const [autoTagDialogOpen, setAutoTagDialogOpen] = useState(false); + const [selectedItem, setSelectedItem] = useState({ + id: '', + name: 'Library', + mode: 'artist' as 'track' | 'album' | 'artist' + }); + const [autoTagOptions, setAutoTagOptions] = useState({ + rateLimit: 1000, // milliseconds between requests + autoProcess: false, + preferLocalMetadata: true, + tagsToUpdate: ['title', 'artist', 'album', 'year', 'genre'], + }); + + useEffect(() => { + setIsClient(true); + + // Load saved preferences from localStorage + const savedAutoTagging = localStorage.getItem('auto-tagging-enabled'); + if (savedAutoTagging !== null) { + setAutoTaggingEnabled(savedAutoTagging === 'true'); + } + + // Load saved auto-tag options + const savedOptions = localStorage.getItem('auto-tagging-options'); + if (savedOptions !== null) { + try { + setAutoTagOptions(JSON.parse(savedOptions)); + } catch (error) { + console.error('Failed to parse stored auto-tagging options:', error); + } + } + }, []); + + const handleAutoTaggingToggle = (enabled: boolean) => { + setAutoTaggingEnabled(enabled); + if (isClient) { + localStorage.setItem('auto-tagging-enabled', enabled.toString()); + } + toast({ + title: enabled ? 'Auto-Tagging Enabled' : 'Auto-Tagging Disabled', + description: enabled + ? 'Music will be automatically tagged with metadata from MusicBrainz' + : 'Auto-tagging has been disabled', + }); + }; + + const handleOptionsChange = (key: string, value: unknown) => { + setAutoTagOptions(prev => { + const newOptions = { ...prev, [key]: value }; + if (isClient) { + localStorage.setItem('auto-tagging-options', JSON.stringify(newOptions)); + } + return newOptions; + }); + }; + + const handleTagSelectionChange = (tag: string, isSelected: boolean) => { + setAutoTagOptions(prev => { + const currentTags = [...prev.tagsToUpdate]; + const newTags = isSelected + ? [...currentTags, tag] + : currentTags.filter(t => t !== tag); + + const newOptions = { ...prev, tagsToUpdate: newTags }; + if (isClient) { + localStorage.setItem('auto-tagging-options', JSON.stringify(newOptions)); + } + return newOptions; + }); + }; + + const isTagSelected = (tag: string) => { + return autoTagOptions.tagsToUpdate.includes(tag); + }; + + return ( + <> + + + + + Auto-Tagging + + + Configure metadata auto-tagging with MusicBrainz + + + +
+
+

Enable Auto-Tagging

+

+ Automatically fetch and apply metadata from MusicBrainz +

+
+ +
+ + {autoTaggingEnabled && ( + <> +
+ + handleOptionsChange('rateLimit', Number(e.target.value))} + /> +

+ Time between API requests in milliseconds (min: 500ms) +

+
+ +
+
+

Auto Process Results

+

+ Automatically apply best matches without confirmation +

+
+ handleOptionsChange('autoProcess', checked)} + /> +
+ +
+
+

Prefer Local Metadata

+

+ Keep existing metadata when confidence is low +

+
+ handleOptionsChange('preferLocalMetadata', checked)} + /> +
+ +
+ +
+ {['title', 'artist', 'album', 'year', 'genre', 'albumArtist', 'trackNumber', 'discNumber'].map(tag => ( +
+ handleTagSelectionChange(tag, checked)} + /> + +
+ ))} +
+
+ +
+ +
+ + )} + +
+

How it works:

+
    +
  • Metadata is fetched from MusicBrainz when you play tracks
  • +
  • Tags can be applied automatically or manually reviewed
  • +
  • Right-click on tracks or albums to tag them manually
  • +
  • MusicBrainz API has rate limits, so don't set too fast
  • +
+
+
+
+ + setAutoTagDialogOpen(false)} + mode={selectedItem.mode} + itemId={selectedItem.id} + itemName={selectedItem.name} + /> + + ); +}; diff --git a/app/components/DraggableMiniPlayer.tsx b/app/components/DraggableMiniPlayer.tsx index fd21552..97123a6 100644 --- a/app/components/DraggableMiniPlayer.tsx +++ b/app/components/DraggableMiniPlayer.tsx @@ -1,12 +1,14 @@ 'use client'; -import React, { useRef, useState, useEffect } from 'react'; +import React, { useRef, useState, useEffect, useCallback } from 'react'; import Image from 'next/image'; import { motion, PanInfo, AnimatePresence } from 'framer-motion'; import { useAudioPlayer, Track } from './AudioPlayerContext'; -import { FaPlay, FaPause, FaExpand, FaForward, FaBackward } from 'react-icons/fa6'; +import { FaPlay, FaPause, FaExpand, FaForward, FaBackward, FaVolumeHigh, FaVolumeXmark } from 'react-icons/fa6'; import { Heart } from 'lucide-react'; import { constrain } from '@/lib/utils'; +import { Progress } from '@/components/ui/progress'; +import { extractDominantColor } from '@/lib/image-utils'; interface DraggableMiniPlayerProps { onExpand: () => void; @@ -24,6 +26,12 @@ export const DraggableMiniPlayer: React.FC = ({ onExpa const [position, setPosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); + const [dominantColor, setDominantColor] = useState(null); + const [progress, setProgress] = useState(0); + const [showVolumeSlider, setShowVolumeSlider] = useState(false); + const [volume, setVolume] = useState(1); + const [clickCount, setClickCount] = useState(0); + const [clickTimer, setClickTimer] = useState(null); const containerRef = useRef(null); const dragStartRef = useRef({ x: 0, y: 0 }); @@ -34,6 +42,105 @@ export const DraggableMiniPlayer: React.FC = ({ onExpa } }, [position, isDragging]); + // Extract dominant color from album art + useEffect(() => { + if (!currentTrack?.coverArt) { + setDominantColor(null); + return; + } + + extractDominantColor(currentTrack.coverArt) + .then(color => setDominantColor(color)) + .catch(error => { + console.error('Failed to extract color:', error); + setDominantColor(null); + }); + }, [currentTrack?.coverArt]); + + // Track progress from main audio player + useEffect(() => { + const updateProgress = () => { + const audioElement = document.querySelector('audio') as HTMLAudioElement | null; + if (audioElement && audioElement.duration) { + setProgress((audioElement.currentTime / audioElement.duration) * 100); + } + }; + + const updateVolume = () => { + const audioElement = document.querySelector('audio') as HTMLAudioElement | null; + if (audioElement) { + setVolume(audioElement.volume); + } + }; + + const interval = setInterval(updateProgress, 250); + updateVolume(); // Initial volume + + // Set up event listener for volume changes + const audioElement = document.querySelector('audio'); + if (audioElement) { + audioElement.addEventListener('volumechange', updateVolume); + } + + return () => { + clearInterval(interval); + if (audioElement) { + audioElement.removeEventListener('volumechange', updateVolume); + } + }; + }, [currentTrack]); + + // Detect double clicks for expanding + const handleContainerClick = useCallback(() => { + setClickCount(prev => prev + 1); + + if (clickTimer) { + clearTimeout(clickTimer); + } + + const timer = setTimeout(() => { + // If single click, do nothing + if (clickCount === 0) { + // Nothing + } + // If double click, expand + else if (clickCount === 1) { + onExpand(); + } + setClickCount(0); + }, 300); + + setClickTimer(timer as unknown as NodeJS.Timeout); + }, [clickCount, clickTimer, onExpand]); + + // Handle seeking in track + const handleProgressClick = (e: React.MouseEvent) => { + e.stopPropagation(); + const audioElement = document.querySelector('audio') as HTMLAudioElement | null; + if (!audioElement) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const percent = clickX / rect.width; + audioElement.currentTime = percent * audioElement.duration; + }; + + // Handle volume change + const handleVolumeChange = (e: React.ChangeEvent) => { + const audioElement = document.querySelector('audio') as HTMLAudioElement | null; + if (!audioElement) return; + + const newVolume = parseFloat(e.target.value); + audioElement.volume = newVolume; + setVolume(newVolume); + + try { + localStorage.setItem('navidrome-volume', newVolume.toString()); + } catch (error) { + console.error('Failed to save volume:', error); + } + }; + // Keyboard controls for the mini player useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -106,7 +213,7 @@ export const DraggableMiniPlayer: React.FC = ({ onExpa } }, []); - // Ensure player stays within viewport bounds + // Ensure player stays within viewport bounds and implement edge snapping useEffect(() => { const constrainToViewport = () => { if (!containerRef.current || isDragging) return; @@ -118,17 +225,41 @@ export const DraggableMiniPlayer: React.FC = ({ onExpa // Add some padding from edges const padding = 16; - const newX = constrain( + // Calculate constrained position + let newX = constrain( position.x, -(viewportWidth - rect.width) / 2 + padding, (viewportWidth - rect.width) / 2 - padding ); - const newY = constrain( + let newY = constrain( position.y, -(viewportHeight - rect.height) / 2 + padding, (viewportHeight - rect.height) / 2 - padding ); + + // Edge snapping logic + const snapThreshold = 24; // Pixels from edge to trigger snap + const snapPositions = { + left: -(viewportWidth - rect.width) / 2 + padding, + right: (viewportWidth - rect.width) / 2 - padding, + top: -(viewportHeight - rect.height) / 2 + padding, + bottom: (viewportHeight - rect.height) / 2 - padding, + }; + + // Snap to left or right edge + if (Math.abs(newX - snapPositions.left) < snapThreshold) { + newX = snapPositions.left; + } else if (Math.abs(newX - snapPositions.right) < snapThreshold) { + newX = snapPositions.right; + } + + // Snap to top or bottom edge + if (Math.abs(newY - snapPositions.top) < snapThreshold) { + newY = snapPositions.top; + } else if (Math.abs(newY - snapPositions.bottom) < snapThreshold) { + newY = snapPositions.bottom; + } if (newX !== position.x || newY !== position.y) { setPosition({ x: newX, y: newY }); @@ -181,18 +312,54 @@ export const DraggableMiniPlayer: React.FC = ({ onExpa transform: `translate(-50%, -50%)` }} className="cursor-grab active:cursor-grabbing" + onClick={handleContainerClick} > -
+
+ {/* Progress bar at the top */} +
+ +
+
- {/* Album Art */} -
- {currentTrack.name} -
+ {/* Album Art - Animated transition */} + + + {currentTrack.name} + + {/* Track Info */}
@@ -201,13 +368,13 @@ export const DraggableMiniPlayer: React.FC = ({ onExpa
- {/* Controls */} {/* Keyboard shortcut hint */} -
- Arrow keys to move • Hold Shift for larger steps • Esc to expand -
+
+ Double-click to expand • Arrow keys to move +
-
+ {/* Controls */} +
- +
+ + + {/* Volume Slider */} + {showVolumeSlider && ( +
e.stopPropagation()} + > + +
+ )} +
+ + {/* Expand button in top-right corner */} +
diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 94eadf7..05b8908 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -16,8 +16,9 @@ import { SidebarCustomization } from '@/app/components/SidebarCustomization'; import { SettingsManagement } from '@/app/components/SettingsManagement'; import { CacheManagement } from '@/app/components/CacheManagement'; import { OfflineManagement } from '@/app/components/OfflineManagement'; -import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog } from 'react-icons/fa'; -import { Settings, ExternalLink } from 'lucide-react'; +import { AutoTaggingSettings } from '@/app/components/AutoTaggingSettings'; +import { FaServer, FaUser, FaLock, FaCheck, FaTimes, FaLastfm, FaCog, FaTags } from 'react-icons/fa'; +import { Settings, ExternalLink, Tag } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; const SettingsPage = () => { @@ -788,6 +789,11 @@ const SettingsPage = () => {
+ {/* Auto-Tagging Settings */} +
+ +
+ Appearance diff --git a/hooks/use-auto-tagging.ts b/hooks/use-auto-tagging.ts new file mode 100644 index 0000000..585f0c5 --- /dev/null +++ b/hooks/use-auto-tagging.ts @@ -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([]); + 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]; +} diff --git a/lib/image-utils.ts b/lib/image-utils.ts index b5bbaf3..7116bf5 100644 --- a/lib/image-utils.ts +++ b/lib/image-utils.ts @@ -123,3 +123,85 @@ export function useOptimalImageSize( const divisions = [60, 120, 240, 400, 600, 1200]; return divisions.find(size => size >= optimalSize) || 1200; } + +/** + * Extract dominant color from an image + * @param imageUrl - URL of the image to analyze + * @returns Promise that resolves to CSS color string (rgb format) + */ +export async function extractDominantColor(imageUrl: string): Promise { + return new Promise((resolve, reject) => { + try { + const img = document.createElement('img'); + img.crossOrigin = 'anonymous'; + + img.onload = () => { + try { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) { + resolve('rgb(25, 25, 25)'); // Fallback dark color + return; + } + + canvas.width = img.width; + canvas.height = img.height; + ctx.drawImage(img, 0, 0); + + // Simple dominant color extraction + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + let r = 0, g = 0, b = 0; + + // Sample points across the image (for performance, not using all pixels) + const sampleSize = Math.max(1, Math.floor(data.length / 4000)); + let sampleCount = 0; + + for (let i = 0; i < data.length; i += 4 * sampleSize) { + r += data[i]; + g += data[i + 1]; + b += data[i + 2]; + sampleCount++; + } + + r = Math.floor(r / sampleCount); + g = Math.floor(g / sampleCount); + b = Math.floor(b / sampleCount); + + // Adjust brightness to ensure readability + const brightness = (r * 299 + g * 587 + b * 114) / 1000; + + // For very light colors, darken them + if (brightness > 200) { + const darkFactor = 0.7; + r = Math.floor(r * darkFactor); + g = Math.floor(g * darkFactor); + b = Math.floor(b * darkFactor); + } + + // For very dark colors, lighten them slightly + if (brightness < 50) { + const lightFactor = 1.3; + r = Math.min(255, Math.floor(r * lightFactor)); + g = Math.min(255, Math.floor(g * lightFactor)); + b = Math.min(255, Math.floor(b * lightFactor)); + } + + resolve(`rgb(${r}, ${g}, ${b})`); + } catch (error) { + console.error('Error extracting color:', error); + resolve('rgb(25, 25, 25)'); // Fallback dark color + } + }; + + img.onerror = () => { + resolve('rgb(25, 25, 25)'); // Fallback dark color + }; + + img.src = imageUrl; + } catch (error) { + console.error('Error loading image for color extraction:', error); + resolve('rgb(25, 25, 25)'); // Fallback dark color + } + }); +} diff --git a/lib/musicbrainz-api.ts b/lib/musicbrainz-api.ts new file mode 100644 index 0000000..76d3ee7 --- /dev/null +++ b/lib/musicbrainz-api.ts @@ -0,0 +1,347 @@ +/** + * MusicBrainz API client for the auto-tagging feature + * + * This module provides functions to search and fetch metadata from MusicBrainz, + * which is an open music encyclopedia that collects music metadata. + */ + +// Define the User-Agent string as per MusicBrainz API guidelines +// https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting#User-Agent +const USER_AGENT = 'mice/1.0.0 (https://github.com/sillyangel/mice)'; + +// Base URL for MusicBrainz API +const API_BASE_URL = 'https://musicbrainz.org/ws/2'; + +// Add a delay between requests to comply with MusicBrainz rate limiting +const RATE_LIMIT_DELAY = 1100; // Slightly more than 1 second to be safe + +// Queue for API requests to ensure proper rate limiting +const requestQueue: (() => Promise)[] = []; +let isProcessingQueue = false; + +/** + * Process the request queue with proper rate limiting + */ +async function processQueue() { + if (isProcessingQueue || requestQueue.length === 0) return; + + isProcessingQueue = true; + + while (requestQueue.length > 0) { + const request = requestQueue.shift(); + if (request) { + try { + await request(); + } catch (error) { + console.error('MusicBrainz API request failed:', error); + } + + // Wait before processing the next request + await new Promise(resolve => setTimeout(resolve, RATE_LIMIT_DELAY)); + } + } + + isProcessingQueue = false; +} + +/** + * Make a rate-limited request to the MusicBrainz API + */ +async function makeRequest(endpoint: string, params: Record = {}): Promise { + return new Promise((resolve, reject) => { + const requestFn = async () => { + try { + const url = new URL(`${API_BASE_URL}${endpoint}`); + + // Add format parameter + url.searchParams.append('fmt', 'json'); + + // Add other parameters + Object.entries(params).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + + const response = await fetch(url.toString(), { + headers: { + 'User-Agent': USER_AGENT + } + }); + + if (!response.ok) { + throw new Error(`MusicBrainz API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + resolve(data as T); + } catch (error) { + reject(error); + } + }; + + // Add request to queue + requestQueue.push(requestFn); + processQueue(); + }); +} + +/** + * Search for releases (albums) in MusicBrainz + */ +export async function searchReleases(query: string, limit: number = 10): Promise { + try { + interface ReleaseSearchResult { + releases: MusicBrainzRelease[]; + } + + const data = await makeRequest('/release', { + query, + limit: limit.toString() + }); + + return data.releases || []; + } catch (error) { + console.error('Failed to search releases:', error); + return []; + } +} + +/** + * Search for recordings (tracks) in MusicBrainz + */ +export async function searchRecordings(query: string, limit: number = 10): Promise { + try { + interface RecordingSearchResult { + recordings: MusicBrainzRecording[]; + } + + const data = await makeRequest('/recording', { + query, + limit: limit.toString() + }); + + return data.recordings || []; + } catch (error) { + console.error('Failed to search recordings:', error); + return []; + } +} + +/** + * Get detailed information about a release by its MBID + */ +export async function getRelease(mbid: string): Promise { + try { + // Request with recording-level relationships to get track-level data + const data = await makeRequest(`/release/${mbid}`, { + inc: 'recordings+artists+labels+artist-credits' + }); + + return data; + } catch (error) { + console.error(`Failed to get release ${mbid}:`, error); + return null; + } +} + +/** + * Get detailed information about a recording by its MBID + */ +export async function getRecording(mbid: string): Promise { + try { + const data = await makeRequest(`/recording/${mbid}`, { + inc: 'artists+releases+artist-credits' + }); + + return data; + } catch (error) { + console.error(`Failed to get recording ${mbid}:`, error); + return null; + } +} + +/** + * Find the best matching release for the given album information + * This uses fuzzy matching to find the most likely match + */ +export async function findBestMatchingRelease( + albumName: string, + artistName: string, + trackCount?: number +): Promise { + try { + // Build a search query with both album and artist + const query = `release:"${albumName}" AND artist:"${artistName}"`; + const releases = await searchReleases(query, 5); + + if (!releases || releases.length === 0) { + return null; + } + + // If track count is provided, prioritize releases with the same track count + if (trackCount !== undefined) { + const exactTrackCountMatch = releases.find(release => + release['track-count'] === trackCount + ); + + if (exactTrackCountMatch) { + return exactTrackCountMatch; + } + } + + // Just return the first result as it's likely the best match + return releases[0]; + } catch (error) { + console.error('Failed to find matching release:', error); + return null; + } +} + +/** + * Find the best matching recording for the given track information + */ +export async function findBestMatchingRecording( + trackName: string, + artistName: string, + duration?: number // in milliseconds +): Promise { + try { + // Build a search query with both track and artist + const query = `recording:"${trackName}" AND artist:"${artistName}"`; + const recordings = await searchRecordings(query, 5); + + if (!recordings || recordings.length === 0) { + return null; + } + + // If duration is provided, try to find a close match + if (duration !== undefined) { + // Convert to milliseconds if not already (MusicBrainz uses milliseconds) + const durationMs = duration < 1000 ? duration * 1000 : duration; + + // Find recording with the closest duration (within 5 seconds) + const durationMatches = recordings.filter(recording => { + if (!recording.length) return false; + return Math.abs(recording.length - durationMs) < 5000; // 5 second tolerance + }); + + if (durationMatches.length > 0) { + return durationMatches[0]; + } + } + + // Just return the first result as it's likely the best match + return recordings[0]; + } catch (error) { + console.error('Failed to find matching recording:', error); + return null; + } +} + +// Type definitions for MusicBrainz API responses + +export interface MusicBrainzRelease { + id: string; // MBID + title: string; + 'artist-credit': Array<{ + artist: { + id: string; + name: string; + }; + name: string; + }>; + date?: string; + country?: string; + 'track-count': number; + status?: string; + disambiguation?: string; +} + +export interface MusicBrainzReleaseDetails extends MusicBrainzRelease { + media: Array<{ + position: number; + format?: string; + tracks: Array<{ + position: number; + number: string; + title: string; + length?: number; + recording: { + id: string; + title: string; + length?: number; + }; + }>; + }>; + 'cover-art-archive'?: { + artwork: boolean; + count: number; + front: boolean; + back: boolean; + }; + barcode?: string; + 'release-group'?: { + id: string; + 'primary-type'?: string; + }; +} + +export interface MusicBrainzRecording { + id: string; // MBID + title: string; + length?: number; // in milliseconds + 'artist-credit': Array<{ + artist: { + id: string; + name: string; + }; + name: string; + }>; + releases?: Array<{ + id: string; + title: string; + }>; + isrcs?: string[]; +} + +export interface MusicBrainzRecordingDetails extends MusicBrainzRecording { + disambiguation?: string; + 'first-release-date'?: string; + genres?: Array<{ + id: string; + name: string; + }>; + tags?: Array<{ + count: number; + name: string; + }>; +} + +// Cover art functions +// MusicBrainz has a separate API for cover art: Cover Art Archive + +export function getCoverArtUrl(releaseId: string, size: 'small' | 'large' | '500' | 'full' = 'large'): string { + return `https://coverartarchive.org/release/${releaseId}/front-${size}`; +} + +// Utility function to normalize strings for comparison +export function normalizeString(input: string): string { + return input + .toLowerCase() + .replace(/[^\w\s]/g, '') // Remove special characters + .replace(/\s+/g, ' ') // Replace multiple spaces with a single space + .trim(); +} + +// Export the MusicBrainz client as a singleton +const MusicBrainzClient = { + searchReleases, + searchRecordings, + getRelease, + getRecording, + findBestMatchingRelease, + findBestMatchingRecording, + getCoverArtUrl, + normalizeString +}; + +export default MusicBrainzClient; diff --git a/lib/navidrome.ts b/lib/navidrome.ts index 1db86db..01685da 100644 --- a/lib/navidrome.ts +++ b/lib/navidrome.ts @@ -215,12 +215,21 @@ class NavidromeAPI { } async getArtist(artistId: string): Promise<{ artist: Artist; albums: Album[] }> { - const response = await this.makeRequest('getArtist', { id: artistId }); - const artistData = response.artist as Artist & { album?: Album[] }; - return { - artist: artistData, - albums: artistData.album || [] - }; + try { + const response = await this.makeRequest('getArtist', { id: artistId }); + // Check if artist data exists + if (!response.artist) { + throw new Error('Artist not found in response'); + } + const artistData = response.artist as Artist & { album?: Album[] }; + return { + artist: artistData, + albums: artistData.album || [] + }; + } catch (error) { + console.error('Navidrome API request failed:', error); + throw new Error('Artist not found'); + } } async getAlbums(type?: 'newest' | 'recent' | 'frequent' | 'random' | 'alphabeticalByName' | 'alphabeticalByArtist' | 'starred' | 'highest', size: number = 500, offset: number = 0): Promise { diff --git a/next.config.mjs b/next.config.mjs index b3590b2..bb74811 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -12,6 +12,8 @@ const nextConfig = { hostname: "**", } ], + minimumCacheTTL: 60, + // unoptimized: true, }, async headers() { return [ @@ -69,6 +71,7 @@ const nextConfig = { }, // This is required to support PostHog trailing slash API requests skipTrailingSlashRedirect: true, + }; export default nextConfig;