refactor(playlist): G.62 — extract CSV match helpers + import orchestrator (cluster) (#629)

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.
This commit is contained in:
Frank Stellmacher
2026-05-13 12:35:48 +02:00
committed by GitHub
parent 84ceb5f423
commit 1b1122f086
3 changed files with 405 additions and 367 deletions
+221
View File
@@ -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<void>;
setSongs: (next: SubsonicSong[]) => void;
setCsvImporting: (v: boolean) => void;
setCsvImportReport: (r: CsvImportReport | null) => void;
}
export async function runPlaylistCsvImport(deps: RunPlaylistCsvImportDeps): Promise<void> {
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);
}
}
+177
View File
@@ -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<T, R>(
items: T[],
batchSize: number,
processor: (item: T) => Promise<R | null>
): 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;
}