mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
committed by
GitHub
parent
84ceb5f423
commit
1b1122f086
@@ -22,8 +22,6 @@ import { useDownloadModalStore } from '../store/downloadModalStore';
|
|||||||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { join } from '@tauri-apps/api/path';
|
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 { useZipDownloadStore } from '../store/zipDownloadStore';
|
||||||
import { useDragDrop } from '../contexts/DragDropContext';
|
import { useDragDrop } from '../contexts/DragDropContext';
|
||||||
import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
||||||
@@ -39,7 +37,8 @@ import {
|
|||||||
displayPlaylistName,
|
displayPlaylistName,
|
||||||
codecLabel,
|
codecLabel,
|
||||||
} from '../utils/playlistDetailHelpers';
|
} 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 PlaylistEditModal from '../components/playlist/PlaylistEditModal';
|
||||||
import CsvImportReportModal from '../components/playlist/CsvImportReportModal';
|
import CsvImportReportModal from '../components/playlist/CsvImportReportModal';
|
||||||
|
|
||||||
@@ -369,372 +368,13 @@ export default function PlaylistDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── CSV Import ──────────────────────────────────────────────
|
// ── 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 <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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImportCsv = async () => {
|
const handleImportCsv = async () => {
|
||||||
if (!id || csvImporting) return;
|
if (!id || csvImporting) return;
|
||||||
|
await runPlaylistCsvImport({
|
||||||
try {
|
songs, t, savePlaylist,
|
||||||
const selected = await openDialog({
|
setSongs, setCsvImporting, setCsvImportReport,
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Remove ────────────────────────────────────────────────────
|
// ── Remove ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user