function isFuzzyMatch(str1, str2) {
if (!str1 || !str2) return false;
const s1 = str1.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
const s2 = str2.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
return s1.includes(s2) || s2.includes(s1);
}
function findBestMatch(items, targetArtist, targetAlbum, importOptions) {
if (!items || items.length === 0) return null;
if (!importOptions?.strictArtistMatch && !importOptions?.strictAlbumMatch) return items[0];
return (
items.find((item) => {
let artistOk = true;
let albumOk = true;
if (importOptions.strictArtistMatch && targetArtist) {
const itemArtist = item.artist?.name || item.artists?.[0]?.name;
if (!isFuzzyMatch(itemArtist, targetArtist)) artistOk = false;
}
if (importOptions.strictAlbumMatch && targetAlbum) {
const itemAlbum = item.album?.title;
if (itemAlbum && !isFuzzyMatch(itemAlbum, targetAlbum)) albumOk = false;
}
return artistOk && albumOk;
}) || null
);
}
/**
* Helper function to get track artists string
*/
function getTrackArtists(track) {
if (track.artists && track.artists.length > 0) {
return track.artists.map((artist) => artist.name).join(', ');
}
return track.artist?.name || 'Unknown Artist';
}
/**
* Generates CSV playlist export
* @param {Object} playlist - Playlist metadata
* @param {Array} tracks - Array of track objects
* @returns {string} CSV content
*/
export function generateCSV(playlist, tracks) {
const headers = ['Track Name', 'Artist Name(s)', 'Album', 'Duration'];
let content = headers.map((h) => `"${h}"`).join(',') + '\n';
tracks.forEach((track) => {
const title = (track.title || '').replace(/"/g, '""');
const artist = getTrackArtists(track).replace(/"/g, '""');
const album = (track.album?.title || '').replace(/"/g, '""');
const duration = formatDuration(track.duration || 0);
content += `"${title}","${artist}","${album}","${duration}"\n`;
});
return content;
}
/**
* Generates XSPF (XML Shareable Playlist Format) export
* @param {Object} playlist - Playlist metadata
* @param {Array} tracks - Array of track objects
* @returns {string} XSPF XML content
*/
export function generateXSPF(playlist, tracks) {
const date = new Date().toISOString();
let xml = '\n';
xml += '\n';
xml += ` ${escapeXml(playlist.title || 'Unknown Playlist')}\n`;
xml += ` ${escapeXml(playlist.artist || 'Various Artists')}\n`;
xml += ` ${date}\n`;
xml += ' \n';
tracks.forEach((track) => {
xml += ' \n';
});
xml += ' \n';
xml += '\n';
return xml;
}
/**
* Generates generic XML playlist export
* @param {Object} playlist - Playlist metadata
* @param {Array} tracks - Array of track objects
* @returns {string} XML content
*/
export function generateXML(playlist, tracks) {
const date = new Date().toISOString();
let xml = '\n';
xml += '\n';
xml += ` ${escapeXml(playlist.title || 'Unknown Playlist')}\n`;
xml += ` ${escapeXml(playlist.artist || 'Various Artists')}\n`;
xml += ` ${date}\n`;
xml += ` ${tracks.length}\n`;
xml += ' \n';
tracks.forEach((track, index) => {
xml += ' \n';
});
xml += ' \n';
xml += '\n';
return xml;
}
/**
* Parses CSV playlist format
* @param {string} csvText - CSV content
* @param {Function} api - API instance for searching tracks
* @param {Function} onProgress - Progress callback
* @returns {Promise<{tracks: Array, missingTracks: Array}>}
*/
const HEADER_MAPPINGS = {
track: ['track name', 'title', 'song', 'name', 'track', 'track title'],
artist: ['artist name(s)', 'artist name', 'artist', 'artists', 'creator', 'artist names'],
album: ['album', 'album name'],
type: ['type', 'category', 'kind'],
isrc: ['isrc', 'isrc code'],
spotifyId: ['spotify - id', 'spotify id', 'spotify_id', 'spotifyid'],
playlistName: ['playlist name', 'playlist', 'playlist title'],
duration: ['duration', 'length', 'time'],
};
function normalizeHeader(header) {
if (!header) return '';
return header
.replace(/^\uFEFF/, '')
.toLowerCase()
.trim()
.replace(/[_\s]+/g, ' ');
}
function mapHeaders(rawHeaders) {
const mapped = {};
rawHeaders.forEach((header, index) => {
const normalized = normalizeHeader(header);
for (const [key, aliases] of Object.entries(HEADER_MAPPINGS)) {
if (aliases.includes(normalized)) {
mapped[key] = index;
break;
}
}
});
return mapped;
}
function detectCSVFormat(mappedHeaders) {
const hasType = mappedHeaders.type !== undefined;
const hasTrack = mappedHeaders.track !== undefined;
const hasArtist = mappedHeaders.artist !== undefined;
const hasAlbum = mappedHeaders.album !== undefined;
if (hasTrack && hasArtist) {
return {
format: 'library',
hasMultipleTypes: hasType,
supportsTracks: true,
supportsAlbums: hasAlbum,
supportsArtists: hasArtist && !hasTrack,
};
}
if (hasArtist && !hasTrack) {
return {
format: 'artists',
hasMultipleTypes: false,
supportsTracks: false,
supportsAlbums: false,
supportsArtists: true,
};
}
return {
format: 'playlist',
hasMultipleTypes: false,
supportsTracks: true,
supportsAlbums: false,
supportsArtists: false,
};
}
export async function parseDynamicCSV(csvText, api, onProgress, options = {}) {
const lines = csvText.trim().split('\n');
if (lines.length < 2) {
return {
format: 'unknown',
tracks: [],
albums: [],
artists: [],
missingItems: [],
playlists: {},
};
}
const parseLine = (text) => {
const values = [];
let current = '';
let inQuote = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (char === '"') {
inQuote = !inQuote;
} else if (char === ',' && !inQuote) {
values.push(current);
current = '';
} else {
current += char;
}
}
values.push(current);
return values.map((v) => v.trim().replace(/^"|"$/g, '').replace(/""/g, '"').trim());
};
const rawHeaders = parseLine(lines[0]);
const mappedHeaders = mapHeaders(rawHeaders);
const formatInfo = detectCSVFormat(mappedHeaders);
const rows = lines.slice(1);
const tracks = [];
const albums = [];
const artists = [];
const missingItems = [];
const playlists = {};
const totalItems = rows.length;
const getItemType = (values) => {
if (mappedHeaders.type !== undefined) {
const typeValue = values[mappedHeaders.type]?.toLowerCase().trim();
if (typeValue === 'album' || typeValue === 'favorite album') return 'album';
if (typeValue === 'artist' || typeValue === 'favorite artist') return 'artist';
if (
typeValue === 'favorite' ||
typeValue === 'favorite track' ||
typeValue === 'track' ||
typeValue === 'playlist'
)
return 'track';
}
const hasTrackName = mappedHeaders.track !== undefined && values[mappedHeaders.track];
const hasArtistName = mappedHeaders.artist !== undefined && values[mappedHeaders.artist];
const hasAlbumName = mappedHeaders.album !== undefined && values[mappedHeaders.album];
if (hasTrackName && hasArtistName) return 'track';
if (hasTrackName && hasAlbumName && values[mappedHeaders.track] === values[mappedHeaders.album]) {
return hasArtistName ? 'track' : 'album';
}
if (hasAlbumName && hasArtistName && !hasTrackName) return 'album';
if (hasArtistName && !hasTrackName && !hasAlbumName) return 'artist';
return 'track';
};
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row.trim()) continue;
const values = parseLine(row);
const itemType = getItemType(values);
const trackName = mappedHeaders.track !== undefined ? values[mappedHeaders.track] : '';
const artistName = mappedHeaders.artist !== undefined ? values[mappedHeaders.artist] : '';
const albumName = mappedHeaders.album !== undefined ? values[mappedHeaders.album] : '';
const isrc = mappedHeaders.isrc !== undefined ? values[mappedHeaders.isrc] : '';
const playlistName = mappedHeaders.playlistName !== undefined ? values[mappedHeaders.playlistName] : '';
const typeValue = mappedHeaders.type !== undefined ? values[mappedHeaders.type]?.toLowerCase().trim() : '';
const isFavorite = typeValue.includes('favorite');
if (onProgress) {
onProgress({
current: i,
total: totalItems,
currentItem: trackName || artistName || albumName || 'Unknown item',
type: itemType,
});
}
await new Promise((resolve) => setTimeout(resolve, 300));
try {
if (itemType === 'track') {
let foundTrack = null;
if (isrc) {
const searchResult = await api.searchTracks(`isrc:${isrc}`);
if (searchResult.items && searchResult.items.length > 0) {
foundTrack = searchResult.items.find((t) => t.isrc === isrc) || searchResult.items[0];
}
}
if (!foundTrack && trackName && artistName) {
const searchQuery = `"${trackName}" ${artistName}`.trim();
const searchResult = await api.searchTracks(searchQuery);
if (searchResult.items && searchResult.items.length > 0) {
foundTrack = findBestMatch(searchResult.items, artistName, albumName, options);
}
}
if (foundTrack) {
if (isFavorite) foundTrack.isFavorite = true;
tracks.push(foundTrack);
if (playlistName) {
if (!playlists[playlistName]) {
playlists[playlistName] = [];
}
playlists[playlistName].push(foundTrack);
}
} else {
missingItems.push({
type: 'track',
title: trackName,
artist: artistName,
album: albumName,
isrc: isrc,
});
}
} else if (itemType === 'album') {
let foundAlbum = null;
if (artistName && albumName) {
const searchQuery = `"${albumName}" ${artistName}`.trim();
const searchResult = await api.searchAlbums(searchQuery);
if (searchResult.items && searchResult.items.length > 0) {
foundAlbum = searchResult.items[0];
}
}
if (foundAlbum) {
if (isFavorite) foundAlbum.isFavorite = true;
albums.push(foundAlbum);
} else {
missingItems.push({
type: 'album',
title: albumName,
artist: artistName,
});
}
} else if (itemType === 'artist') {
let foundArtist = null;
if (artistName) {
const searchResult = await api.searchArtists(artistName);
if (searchResult.items && searchResult.items.length > 0) {
foundArtist = searchResult.items[0];
}
}
if (foundArtist) {
if (isFavorite) foundArtist.isFavorite = true;
artists.push(foundArtist);
} else {
missingItems.push({
type: 'artist',
name: artistName,
});
}
}
} catch {
missingItems.push({
type: itemType,
title: trackName || albumName,
artist: artistName,
});
}
}
return {
format: formatInfo.format,
tracks,
albums,
artists,
missingItems,
playlists,
stats: {
totalItems,
tracksFound: tracks.length,
albumsFound: albums.length,
artistsFound: artists.length,
missingCount: missingItems.length,
playlistCount: Object.keys(playlists).length,
},
};
}
/**
* Imports CSV result to library
* @param {Object} csvResult - Result from parseDynamicCSV
* @param {Object} db - Database instance
* @param {Function} onProgress - Progress callback
* @param {Object} options - Import options
* @returns {Promise