From 1b1122f0860b10698087e41dfaf9ef586242a18b Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Wed, 13 May 2026 12:35:48 +0200 Subject: [PATCH] =?UTF-8?q?refactor(playlist):=20G.62=20=E2=80=94=20extrac?= =?UTF-8?q?t=20CSV=20match=20helpers=20+=20import=20orchestrator=20(cluste?= =?UTF-8?q?r)=20(#629)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-cut cluster on PlaylistDetail.tsx. 1761 → 1400 LOC (−361). The page keeps the same handleImportCsv arrow function (now a thin guard + delegate), but the matching algorithm and the CSV import pipeline live in their own modules. spotifyCsvMatch — six pure helpers: normalizeForMatching, cleanTrackTitle (the 100-LOC suffix-regex array), levenshtein, similarityScore, calculateDynamicThreshold, processBatch. All exports. No state, no side effects, no React deps. runPlaylistCsvImport — full Spotify CSV import pipeline: openDialog + readTextFile, parseSpotifyCsv parse step, batched search with 2-attempt retry, dynamic-threshold scored matching with ISRC fast path, dedupe against existing + already-queued, savePlaylist commit, auto-show report modal on issues, toast variant by outcome (success / warning / error). Takes a deps object with songs / t / savePlaylist + setSongs / setCsvImporting / setCsvImportReport setters. PlaylistDetail keeps the `if (!id || csvImporting) return;` guard on the caller side; id is no longer needed inside the runner (savePlaylist captures it). PlaylistDetail drops three direct imports (search, openDialog, readTextFile, parseSpotifyCsv) and picks up runPlaylistCsvImport. search comes back as a top-level import because the search-add overlay useEffect on the same page still hits the Subsonic search endpoint directly. SpotifyCsvTrack stays as a type-only import for the csvImportReport state shape. Pure code move otherwise — no behaviour change. --- src/pages/PlaylistDetail.tsx | 374 +----------------------------- src/utils/runPlaylistCsvImport.ts | 221 ++++++++++++++++++ src/utils/spotifyCsvMatch.ts | 177 ++++++++++++++ 3 files changed, 405 insertions(+), 367 deletions(-) create mode 100644 src/utils/runPlaylistCsvImport.ts create mode 100644 src/utils/spotifyCsvMatch.ts diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index bcfd4e6f..4947a427 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -22,8 +22,6 @@ import { useDownloadModalStore } from '../store/downloadModalStore'; import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { invoke } from '@tauri-apps/api/core'; import { join } from '@tauri-apps/api/path'; -import { open as openDialog } from '@tauri-apps/plugin-dialog'; -import { readTextFile } from '@tauri-apps/plugin-fs'; import { useZipDownloadStore } from '../store/zipDownloadStore'; import { useDragDrop } from '../contexts/DragDropContext'; import CachedImage, { useCachedUrl } from '../components/CachedImage'; @@ -39,7 +37,8 @@ import { displayPlaylistName, codecLabel, } from '../utils/playlistDetailHelpers'; -import { parseSpotifyCsv, type SpotifyCsvTrack } from '../utils/spotifyCsvImport'; +import type { SpotifyCsvTrack } from '../utils/spotifyCsvImport'; +import { runPlaylistCsvImport } from '../utils/runPlaylistCsvImport'; import PlaylistEditModal from '../components/playlist/PlaylistEditModal'; import CsvImportReportModal from '../components/playlist/CsvImportReportModal'; @@ -369,372 +368,13 @@ export default function PlaylistDetail() { } }; - // ── CSV Import ────────────────────────────────────────────── - // Normalize strings for matching: remove accents, special chars, lowercase, trim - const normalizeForMatching = (s: string): string => - s.toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[ø]/gi, 'o') - .replace(/[æ]/gi, 'ae') - .trim(); - - // Clean common title suffixes (remastered, live, editions, etc.) - const cleanTrackTitle = (title: string): string => { - const suffixes = [ - // Remastered variants - /\s*-\s*remasterizado$/i, - /\s*-\s*remaster$/i, - /\s*-\s*remastered$/i, - /\s*\(remasterizado\)$/i, - /\s*\(remaster\)$/i, - /\s*\(remastered\)$/i, - /\s*\[remasterizado\]$/i, - /\s*\[remaster\]$/i, - /\s*\[remastered\]$/i, - /\s*-\s*remasterizado\s+\d{4}$/i, - /\s*-\s*remastered\s+\d{4}$/i, - /\s*\(\d{4}\s+remaster\)$/i, - /\s*\(\d{4}\s+remastered\)$/i, - // Live variants - /\s*-\s*en vivo$/i, - /\s*-\s*live$/i, - /\s*-\s*version en vivo$/i, - /\s*-\s*studio version$/i, - /\s*-\s*version de estudio$/i, - /\s*\(en vivo\)$/i, - /\s*\(live\)$/i, - /\s*\(live .*\)$/i, - /\s*\[en vivo\]$/i, - /\s*\[live\]$/i, - /\s*\[live .*\]$/i, - /\s*-\s*live at.*$/i, - /\s*\(live at.*\)$/i, - // Version/Edition variants - /\s*-\s*version$/i, - /\s*-\s*versión$/i, - /\s*\(version\)$/i, - /\s*\(versión\)$/i, - /\s*\[version\]$/i, - /\s*\[versión\]$/i, - /\s*-\s*album version$/i, - /\s*\(album version\)$/i, - /\s*\[album version\]$/i, - // Radio/Edit variants - /\s*-\s*radio edit$/i, - /\s*-\s*radio version$/i, - /\s*\(radio edit\)$/i, - /\s*\(radio version\)$/i, - /\s*\[radio edit\]$/i, - /\s*\[radio version\]$/i, - /\s*-\s*edit$/i, - /\s*\(edit\)$/i, - /\s*\[edit\]$/i, - // Acoustic/Instrumental variants - /\s*-\s*acoustic$/i, - /\s*-\s*acústico$/i, - /\s*\(acoustic\)$/i, - /\s*\(acústico\)$/i, - /\s*\[acoustic\]$/i, - /\s*\[acústico\]$/i, - /\s*-\s*instrumental$/i, - /\s*\(instrumental\)$/i, - /\s*\[instrumental\]$/i, - // Featuring/Feat/Ft/With variants - /\s*\(feat\.?\s+.*\)$/i, - /\s*\[feat\.?\s+.*\]$/i, - /\s*-\s*feat\.?\s+.*$/i, - /\s*\(featuring\s+.*\)$/i, - /\s*\[featuring\s+.*\]$/i, - /\s*\(ft\.?\s+.*\)$/i, - /\s*\[ft\.?\s+.*\]$/i, - /\s*-\s*ft\.?\s+.*$/i, - /\s*\(with\s+.*\)$/i, - /\s*\[with\s+.*\]$/i, - /\s*-\s*with\s+.*$/i, - /\s*ft\.?\s+.*$/i, - // Explicit/Clean tags - /\s*\(explicit\)$/i, - /\s*\[explicit\]$/i, - /\s*\(clean\)$/i, - /\s*\[clean\]$/i, - // Mono/Stereo - /\s*\(mono\)$/i, - /\s*\[mono\]$/i, - /\s*\(stereo\)$/i, - /\s*\[stereo\]$/i, - // Deluxe/Special editions - /\s*-\s*deluxe$/i, - /\s*\(deluxe\)$/i, - /\s*\[deluxe\]$/i, - /\s*-\s*special edition$/i, - /\s*\(special edition\)$/i, - /\s*\[special edition\]$/i, - // Year in parentheses (common in remasters) - /\s*\(\d{4}\)$/i, - ]; - let cleaned = title.trim(); - // Apply patterns multiple times for nested cases - for (let i = 0; i < 3; i++) { - const previous = cleaned; - for (const regex of suffixes) { - cleaned = cleaned.replace(regex, ''); - } - cleaned = cleaned.trim(); - if (previous === cleaned) break; // No more changes - } - return cleaned; - }; - - // Levenshtein distance for similarity scoring - const levenshtein = (a: string, b: string): number => { - const matrix: number[][] = []; - for (let i = 0; i <= b.length; i++) matrix[i] = [i]; - for (let j = 0; j <= a.length; j++) matrix[0][j] = j; - for (let i = 1; i <= b.length; i++) { - for (let j = 1; j <= a.length; j++) { - matrix[i][j] = b[i - 1] === a[j - 1] - ? matrix[i - 1][j - 1] - : Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1); - } - } - return matrix[b.length][a.length]; - }; - - const similarityScore = (a: string, b: string): number => { - const maxLen = Math.max(a.length, b.length); - if (maxLen === 0) return 1; - // Use normalized strings (without accents) for comparison - const dist = levenshtein(normalizeForMatching(a), normalizeForMatching(b)); - return 1 - dist / maxLen; - }; - - // Calculate dynamic threshold based on match quality signals - const calculateDynamicThreshold = ( - bestMatch: { score: number; artistScore: number }, - secondMatch: { score: number } | undefined, - titleWords: number - ): number => { - const baseThreshold = 0.6; // Minimum acceptable score - - // Bonus if there's a large gap between best and second match (clear winner) - const gap = secondMatch ? bestMatch.score - secondMatch.score : 0.3; - const gapBonus = gap > 0.15 ? 0.1 : gap > 0.08 ? 0.05 : 0; - - // Short titles (< 3 words) are more ambiguous, need higher threshold - // Long titles (> 4 words) are more specific, can use lower threshold - const lengthBonus = titleWords > 4 ? 0.05 : titleWords < 3 ? -0.05 : 0; - - // Strong artist match gives confidence to accept lower overall score - const artistBonus = bestMatch.artistScore > 0.85 ? 0.08 : bestMatch.artistScore > 0.7 ? 0.04 : 0; - - // Calculate final threshold, clamp between 0.55 and 0.75 - return Math.max(0.55, Math.min(0.75, baseThreshold - gapBonus - lengthBonus - artistBonus)); - }; - - // Process searches in batches to avoid overloading the server - const processBatch = async ( - items: T[], - batchSize: number, - processor: (item: T) => Promise - ): Promise<(R | null)[]> => { - const results: (R | null)[] = []; - for (let i = 0; i < items.length; i += batchSize) { - const batch = items.slice(i, i + batchSize); - const batchResults = await Promise.all(batch.map(processor)); - results.push(...batchResults); - } - return results; - }; - + // ── CSV Import ──────────────────────────────────────────────── const handleImportCsv = async () => { if (!id || csvImporting) return; - - try { - const selected = await openDialog({ - filters: [{ name: 'CSV Files', extensions: ['csv'] }], - multiple: false, - title: 'Import Spotify Playlist CSV', - }); - - if (!selected || typeof selected !== 'string') return; - - setCsvImporting(true); - const content = await readTextFile(selected); - const csvTracks = parseSpotifyCsv(content); - - if (csvTracks.length === 0) { - showToast(t('playlists.csvImportNoValidTracks'), 3000, 'error'); - setCsvImporting(false); - return; - } - - const existingIds = new Set(songs.map(s => s.id)); - const addedSongs: SubsonicSong[] = []; - const notFound: SpotifyCsvTrack[] = []; - const searchErrors: SpotifyCsvTrack[] = []; - const duplicateTracks: SpotifyCsvTrack[] = []; - let duplicateCount = 0; - - // Process in batches of 10 to balance speed/server load - await processBatch(csvTracks, 10, async (track) => { - try { - // Retry: 2 attempts in case of network error - let searchResult; - let attempts = 0; - const maxAttempts = 2; - - // Clean title before search to find matches despite version suffixes - const cleanTitleForSearch = cleanTrackTitle(track.trackName); - - while (attempts < maxAttempts) { - try { - searchResult = await search(cleanTitleForSearch, { songCount: 40, artistCount: 0, albumCount: 0 }); - break; - } catch (err) { - attempts++; - if (attempts >= maxAttempts) throw err; - // Wait 500ms before retrying - await new Promise(r => setTimeout(r, 500)); - } - } - - if (!searchResult || searchResult.songs.length === 0) { - notFound.push({ - ...track, - score: 0, - thresholdNeeded: 0.6, // Minimum threshold, nothing to compare - }); - return null; - } - - // Confidence scoring for each result - // Clean CSV title for fair comparison - const cleanCsvTitle = cleanTrackTitle(track.trackName); - - const scoredMatches = searchResult.songs.map(s => { - // Fast ISRC path: if both have ISRC and they match, perfect score - if (track.isrc && s.isrc && typeof s.isrc === 'string' && track.isrc.toUpperCase() === s.isrc.toUpperCase()) { - return { song: s, score: 1.0, titleScore: 1.0, artistScore: 1.0, isrcMatch: true }; - } - - // Clean the result title as well - const cleanResultTitle = cleanTrackTitle(s.title); - - const titleScore = similarityScore(cleanResultTitle, cleanCsvTitle); - // Artist scoring: maximum score against any of the CSV artists - const artistScore = s.artist - ? Math.max(...track.artistNames.map(csvArtist => - similarityScore(s.artist || '', csvArtist) - )) - : 0; - // If no album in CSV or local, use 1.0 (neutral) to avoid penalizing - const albumScore = (s.album && track.albumName) - ? similarityScore(s.album, track.albumName) - : 1.0; - - // Dynamic weight: specific titles (>4 words) → more weight to title - const titleWords = cleanCsvTitle.split(/\s+/).length; - const isSpecificTitle = titleWords > 4; - const titleWeight = isSpecificTitle ? 0.55 : 0.4; - const artistWeight = isSpecificTitle ? 0.25 : 0.4; - - const totalScore = artistScore * artistWeight + titleScore * titleWeight + albumScore * 0.2; - - return { song: s, score: totalScore, titleScore, artistScore, albumScore, isrcMatch: false }; - }).sort((a, b) => b.score - a.score); - - // Use dynamic threshold based on match quality signals - const bestMatch = scoredMatches[0]; - const secondMatch = scoredMatches[1]; - const titleWords = cleanCsvTitle.split(/\s+/).length; - - const threshold = calculateDynamicThreshold(bestMatch, secondMatch, titleWords); - - if (bestMatch.score < threshold) { - notFound.push({ - ...track, - score: bestMatch.score, - thresholdNeeded: threshold, - }); - return null; - } - - // Check for duplicates - if (existingIds.has(bestMatch.song.id)) { - duplicateCount++; - duplicateTracks.push(track); - return null; - } - - // Check for duplicates in tracks already queued for addition - if (addedSongs.some(s => s.id === bestMatch.song.id)) { - duplicateCount++; - duplicateTracks.push(track); - return null; - } - - addedSongs.push(bestMatch.song); - existingIds.add(bestMatch.song.id); - return bestMatch.song; - } catch { - searchErrors.push(track); - return null; - } - }); - - if (addedSongs.length > 0) { - const next = [...songs, ...addedSongs]; - setSongs(next); - await savePlaylist(next); - } - - // Auto-show report if there are not found tracks, duplicates, or search errors - if (notFound.length > 0 || duplicateCount > 0 || searchErrors.length > 0) { - // Small delay to let the toast appear first - setTimeout(() => { - setCsvImportReport({ - added: addedSongs.length, - notFound, - duplicates: duplicateCount, - duplicateTracks, - total: csvTracks.length, - searchErrors, - }); - }, 500); - } - - const errorMsg = searchErrors.length > 0 - ? ` (${searchErrors.length} network errors - may retry)` - : ''; - - // Determine toast type based on results: - // - success: all songs were added successfully - // - warning: at least one added, but some not found/duplicates - // - error: none added (all duplicates or not found) - const hasAdded = addedSongs.length > 0; - const hasIssues = notFound.length > 0 || duplicateCount > 0 || searchErrors.length > 0; - - let toastVariant: 'success' | 'warning' | 'error'; - if (hasAdded && !hasIssues) { - toastVariant = 'success'; - } else if (hasAdded && hasIssues) { - toastVariant = 'warning'; - } else { - toastVariant = 'error'; - } - - showToast( - t('playlists.csvImportToast', { added: addedSongs.length, notFound: notFound.length, duplicates: duplicateCount }) + errorMsg, - 5000, - toastVariant - ); - } catch (err) { - console.error('CSV import failed:', err); - showToast(t('playlists.csvImportFailed'), 3000, 'error'); - } finally { - setCsvImporting(false); - } + await runPlaylistCsvImport({ + songs, t, savePlaylist, + setSongs, setCsvImporting, setCsvImportReport, + }); }; // ── Remove ──────────────────────────────────────────────────── diff --git a/src/utils/runPlaylistCsvImport.ts b/src/utils/runPlaylistCsvImport.ts new file mode 100644 index 00000000..9cd44f8d --- /dev/null +++ b/src/utils/runPlaylistCsvImport.ts @@ -0,0 +1,221 @@ +import type { TFunction } from 'i18next'; +import { open as openDialog } from '@tauri-apps/plugin-dialog'; +import { readTextFile } from '@tauri-apps/plugin-fs'; +import { search } from '../api/subsonicSearch'; +import type { SubsonicSong } from '../api/subsonicTypes'; +import { showToast } from './toast'; +import { parseSpotifyCsv, type SpotifyCsvTrack } from './spotifyCsvImport'; +import { + cleanTrackTitle, + similarityScore, + calculateDynamicThreshold, + processBatch, +} from './spotifyCsvMatch'; + +export interface CsvImportReport { + added: number; + notFound: SpotifyCsvTrack[]; + duplicates: number; + duplicateTracks: SpotifyCsvTrack[]; + total: number; + searchErrors?: SpotifyCsvTrack[]; +} + +export interface RunPlaylistCsvImportDeps { + songs: SubsonicSong[]; + t: TFunction; + savePlaylist: (updatedSongs: SubsonicSong[], prevCount?: number) => Promise; + setSongs: (next: SubsonicSong[]) => void; + setCsvImporting: (v: boolean) => void; + setCsvImportReport: (r: CsvImportReport | null) => void; +} + +export async function runPlaylistCsvImport(deps: RunPlaylistCsvImportDeps): Promise { + const { songs, t, savePlaylist, setSongs, setCsvImporting, setCsvImportReport } = deps; + + try { + const selected = await openDialog({ + filters: [{ name: 'CSV Files', extensions: ['csv'] }], + multiple: false, + title: 'Import Spotify Playlist CSV', + }); + + if (!selected || typeof selected !== 'string') return; + + setCsvImporting(true); + const content = await readTextFile(selected); + const csvTracks = parseSpotifyCsv(content); + + if (csvTracks.length === 0) { + showToast(t('playlists.csvImportNoValidTracks'), 3000, 'error'); + setCsvImporting(false); + return; + } + + const existingIds = new Set(songs.map(s => s.id)); + const addedSongs: SubsonicSong[] = []; + const notFound: SpotifyCsvTrack[] = []; + const searchErrors: SpotifyCsvTrack[] = []; + const duplicateTracks: SpotifyCsvTrack[] = []; + let duplicateCount = 0; + + // Process in batches of 10 to balance speed/server load + await processBatch(csvTracks, 10, async (track) => { + try { + // Retry: 2 attempts in case of network error + let searchResult; + let attempts = 0; + const maxAttempts = 2; + + // Clean title before search to find matches despite version suffixes + const cleanTitleForSearch = cleanTrackTitle(track.trackName); + + while (attempts < maxAttempts) { + try { + searchResult = await search(cleanTitleForSearch, { songCount: 40, artistCount: 0, albumCount: 0 }); + break; + } catch (err) { + attempts++; + if (attempts >= maxAttempts) throw err; + // Wait 500ms before retrying + await new Promise(r => setTimeout(r, 500)); + } + } + + if (!searchResult || searchResult.songs.length === 0) { + notFound.push({ + ...track, + score: 0, + thresholdNeeded: 0.6, // Minimum threshold, nothing to compare + }); + return null; + } + + // Confidence scoring for each result + // Clean CSV title for fair comparison + const cleanCsvTitle = cleanTrackTitle(track.trackName); + + const scoredMatches = searchResult.songs.map(s => { + // Fast ISRC path: if both have ISRC and they match, perfect score + if (track.isrc && s.isrc && typeof s.isrc === 'string' && track.isrc.toUpperCase() === s.isrc.toUpperCase()) { + return { song: s, score: 1.0, titleScore: 1.0, artistScore: 1.0, isrcMatch: true }; + } + + // Clean the result title as well + const cleanResultTitle = cleanTrackTitle(s.title); + + const titleScore = similarityScore(cleanResultTitle, cleanCsvTitle); + // Artist scoring: maximum score against any of the CSV artists + const artistScore = s.artist + ? Math.max(...track.artistNames.map(csvArtist => + similarityScore(s.artist || '', csvArtist) + )) + : 0; + // If no album in CSV or local, use 1.0 (neutral) to avoid penalizing + const albumScore = (s.album && track.albumName) + ? similarityScore(s.album, track.albumName) + : 1.0; + + // Dynamic weight: specific titles (>4 words) → more weight to title + const titleWords = cleanCsvTitle.split(/\s+/).length; + const isSpecificTitle = titleWords > 4; + const titleWeight = isSpecificTitle ? 0.55 : 0.4; + const artistWeight = isSpecificTitle ? 0.25 : 0.4; + + const totalScore = artistScore * artistWeight + titleScore * titleWeight + albumScore * 0.2; + + return { song: s, score: totalScore, titleScore, artistScore, albumScore, isrcMatch: false }; + }).sort((a, b) => b.score - a.score); + + // Use dynamic threshold based on match quality signals + const bestMatch = scoredMatches[0]; + const secondMatch = scoredMatches[1]; + const titleWords = cleanCsvTitle.split(/\s+/).length; + + const threshold = calculateDynamicThreshold(bestMatch, secondMatch, titleWords); + + if (bestMatch.score < threshold) { + notFound.push({ + ...track, + score: bestMatch.score, + thresholdNeeded: threshold, + }); + return null; + } + + // Check for duplicates + if (existingIds.has(bestMatch.song.id)) { + duplicateCount++; + duplicateTracks.push(track); + return null; + } + + // Check for duplicates in tracks already queued for addition + if (addedSongs.some(s => s.id === bestMatch.song.id)) { + duplicateCount++; + duplicateTracks.push(track); + return null; + } + + addedSongs.push(bestMatch.song); + existingIds.add(bestMatch.song.id); + return bestMatch.song; + } catch { + searchErrors.push(track); + return null; + } + }); + + if (addedSongs.length > 0) { + const next = [...songs, ...addedSongs]; + setSongs(next); + await savePlaylist(next); + } + + // Auto-show report if there are not found tracks, duplicates, or search errors + if (notFound.length > 0 || duplicateCount > 0 || searchErrors.length > 0) { + // Small delay to let the toast appear first + setTimeout(() => { + setCsvImportReport({ + added: addedSongs.length, + notFound, + duplicates: duplicateCount, + duplicateTracks, + total: csvTracks.length, + searchErrors, + }); + }, 500); + } + + const errorMsg = searchErrors.length > 0 + ? ` (${searchErrors.length} network errors - may retry)` + : ''; + + // Determine toast type based on results: + // - success: all songs were added successfully + // - warning: at least one added, but some not found/duplicates + // - error: none added (all duplicates or not found) + const hasAdded = addedSongs.length > 0; + const hasIssues = notFound.length > 0 || duplicateCount > 0 || searchErrors.length > 0; + + let toastVariant: 'success' | 'warning' | 'error'; + if (hasAdded && !hasIssues) { + toastVariant = 'success'; + } else if (hasAdded && hasIssues) { + toastVariant = 'warning'; + } else { + toastVariant = 'error'; + } + + showToast( + t('playlists.csvImportToast', { added: addedSongs.length, notFound: notFound.length, duplicates: duplicateCount }) + errorMsg, + 5000, + toastVariant + ); + } catch (err) { + console.error('CSV import failed:', err); + showToast(t('playlists.csvImportFailed'), 3000, 'error'); + } finally { + setCsvImporting(false); + } +} diff --git a/src/utils/spotifyCsvMatch.ts b/src/utils/spotifyCsvMatch.ts new file mode 100644 index 00000000..be7dd905 --- /dev/null +++ b/src/utils/spotifyCsvMatch.ts @@ -0,0 +1,177 @@ +// Normalize strings for matching: remove accents, special chars, lowercase, trim +export function normalizeForMatching(s: string): string { + return s.toLowerCase() + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/[ø]/gi, 'o') + .replace(/[æ]/gi, 'ae') + .trim(); +} + +// Clean common title suffixes (remastered, live, editions, etc.) +export function cleanTrackTitle(title: string): string { + const suffixes = [ + // Remastered variants + /\s*-\s*remasterizado$/i, + /\s*-\s*remaster$/i, + /\s*-\s*remastered$/i, + /\s*\(remasterizado\)$/i, + /\s*\(remaster\)$/i, + /\s*\(remastered\)$/i, + /\s*\[remasterizado\]$/i, + /\s*\[remaster\]$/i, + /\s*\[remastered\]$/i, + /\s*-\s*remasterizado\s+\d{4}$/i, + /\s*-\s*remastered\s+\d{4}$/i, + /\s*\(\d{4}\s+remaster\)$/i, + /\s*\(\d{4}\s+remastered\)$/i, + // Live variants + /\s*-\s*en vivo$/i, + /\s*-\s*live$/i, + /\s*-\s*version en vivo$/i, + /\s*-\s*studio version$/i, + /\s*-\s*version de estudio$/i, + /\s*\(en vivo\)$/i, + /\s*\(live\)$/i, + /\s*\(live .*\)$/i, + /\s*\[en vivo\]$/i, + /\s*\[live\]$/i, + /\s*\[live .*\]$/i, + /\s*-\s*live at.*$/i, + /\s*\(live at.*\)$/i, + // Version/Edition variants + /\s*-\s*version$/i, + /\s*-\s*versión$/i, + /\s*\(version\)$/i, + /\s*\(versión\)$/i, + /\s*\[version\]$/i, + /\s*\[versión\]$/i, + /\s*-\s*album version$/i, + /\s*\(album version\)$/i, + /\s*\[album version\]$/i, + // Radio/Edit variants + /\s*-\s*radio edit$/i, + /\s*-\s*radio version$/i, + /\s*\(radio edit\)$/i, + /\s*\(radio version\)$/i, + /\s*\[radio edit\]$/i, + /\s*\[radio version\]$/i, + /\s*-\s*edit$/i, + /\s*\(edit\)$/i, + /\s*\[edit\]$/i, + // Acoustic/Instrumental variants + /\s*-\s*acoustic$/i, + /\s*-\s*acústico$/i, + /\s*\(acoustic\)$/i, + /\s*\(acústico\)$/i, + /\s*\[acoustic\]$/i, + /\s*\[acústico\]$/i, + /\s*-\s*instrumental$/i, + /\s*\(instrumental\)$/i, + /\s*\[instrumental\]$/i, + // Featuring/Feat/Ft/With variants + /\s*\(feat\.?\s+.*\)$/i, + /\s*\[feat\.?\s+.*\]$/i, + /\s*-\s*feat\.?\s+.*$/i, + /\s*\(featuring\s+.*\)$/i, + /\s*\[featuring\s+.*\]$/i, + /\s*\(ft\.?\s+.*\)$/i, + /\s*\[ft\.?\s+.*\]$/i, + /\s*-\s*ft\.?\s+.*$/i, + /\s*\(with\s+.*\)$/i, + /\s*\[with\s+.*\]$/i, + /\s*-\s*with\s+.*$/i, + /\s*ft\.?\s+.*$/i, + // Explicit/Clean tags + /\s*\(explicit\)$/i, + /\s*\[explicit\]$/i, + /\s*\(clean\)$/i, + /\s*\[clean\]$/i, + // Mono/Stereo + /\s*\(mono\)$/i, + /\s*\[mono\]$/i, + /\s*\(stereo\)$/i, + /\s*\[stereo\]$/i, + // Deluxe/Special editions + /\s*-\s*deluxe$/i, + /\s*\(deluxe\)$/i, + /\s*\[deluxe\]$/i, + /\s*-\s*special edition$/i, + /\s*\(special edition\)$/i, + /\s*\[special edition\]$/i, + // Year in parentheses (common in remasters) + /\s*\(\d{4}\)$/i, + ]; + let cleaned = title.trim(); + // Apply patterns multiple times for nested cases + for (let i = 0; i < 3; i++) { + const previous = cleaned; + for (const regex of suffixes) { + cleaned = cleaned.replace(regex, ''); + } + cleaned = cleaned.trim(); + if (previous === cleaned) break; // No more changes + } + return cleaned; +} + +// Levenshtein distance for similarity scoring +export function levenshtein(a: string, b: string): number { + const matrix: number[][] = []; + for (let i = 0; i <= b.length; i++) matrix[i] = [i]; + for (let j = 0; j <= a.length; j++) matrix[0][j] = j; + for (let i = 1; i <= b.length; i++) { + for (let j = 1; j <= a.length; j++) { + matrix[i][j] = b[i - 1] === a[j - 1] + ? matrix[i - 1][j - 1] + : Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1); + } + } + return matrix[b.length][a.length]; +} + +export function similarityScore(a: string, b: string): number { + const maxLen = Math.max(a.length, b.length); + if (maxLen === 0) return 1; + // Use normalized strings (without accents) for comparison + const dist = levenshtein(normalizeForMatching(a), normalizeForMatching(b)); + return 1 - dist / maxLen; +} + +// Calculate dynamic threshold based on match quality signals +export function calculateDynamicThreshold( + bestMatch: { score: number; artistScore: number }, + secondMatch: { score: number } | undefined, + titleWords: number +): number { + const baseThreshold = 0.6; // Minimum acceptable score + + // Bonus if there's a large gap between best and second match (clear winner) + const gap = secondMatch ? bestMatch.score - secondMatch.score : 0.3; + const gapBonus = gap > 0.15 ? 0.1 : gap > 0.08 ? 0.05 : 0; + + // Short titles (< 3 words) are more ambiguous, need higher threshold + // Long titles (> 4 words) are more specific, can use lower threshold + const lengthBonus = titleWords > 4 ? 0.05 : titleWords < 3 ? -0.05 : 0; + + // Strong artist match gives confidence to accept lower overall score + const artistBonus = bestMatch.artistScore > 0.85 ? 0.08 : bestMatch.artistScore > 0.7 ? 0.04 : 0; + + // Calculate final threshold, clamp between 0.55 and 0.75 + return Math.max(0.55, Math.min(0.75, baseThreshold - gapBonus - lengthBonus - artistBonus)); +} + +// Process searches in batches to avoid overloading the server +export async function processBatch( + items: T[], + batchSize: number, + processor: (item: T) => Promise +): Promise<(R | null)[]> { + const results: (R | null)[] = []; + for (let i = 0; i < items.length; i += batchSize) { + const batch = items.slice(i, i + batchSize); + const batchResults = await Promise.all(batch.map(processor)); + results.push(...batchResults); + } + return results; +}