favourites importing

This commit is contained in:
Eduard Prigoana 2026-02-22 21:31:53 +00:00
parent 1aaf2dfd46
commit fe6b1e9fad
2 changed files with 386 additions and 17 deletions

View file

@ -48,7 +48,15 @@ import {
trackOpenLyrics,
trackCloseLyrics,
} from './analytics.js';
import { parseCSV, parseJSPF, parseXSPF, parseXML, parseM3U } from './playlist-importer.js';
import {
parseCSV,
parseJSPF,
parseXSPF,
parseXML,
parseM3U,
parseDynamicCSV,
importToLibrary,
} from './playlist-importer.js';
// Lazy-loaded modules
let settingsModule = null;
@ -1316,8 +1324,6 @@ document.addEventListener('DOMContentLoaded', async () => {
}, 1000);
}
} else if (csvFileInput.files.length > 0) {
// Import from CSV
importSource = 'csv_import';
const file = csvFileInput.files[0];
const {
progressElement,
@ -1337,20 +1343,60 @@ document.addEventListener('DOMContentLoaded', async () => {
const csvText = await file.text();
const lines = csvText.trim().split('\n');
const totalTracks = Math.max(0, lines.length - 1);
progressTotal.textContent = totalTracks.toString();
const totalItems = Math.max(0, lines.length - 1);
progressTotal.textContent = totalItems.toString();
const result = await parseCSV(csvText, api, (progress) => {
const percentage = totalTracks > 0 ? (progress.current / totalTracks) * 100 : 0;
const result = await parseDynamicCSV(csvText, api, (progress) => {
const percentage = totalItems > 0 ? (progress.current / totalItems) * 100 : 0;
progressFill.style.width = `${Math.min(percentage, 100)}%`;
progressCurrent.textContent = progress.current.toString();
currentTrackElement.textContent = progress.currentTrack;
if (currentArtistElement)
currentArtistElement.textContent = progress.currentArtist || '';
currentTrackElement.textContent = progress.currentItem;
if (currentArtistElement) {
currentArtistElement.textContent = progress.type
? `Importing ${progress.type}...`
: '';
}
});
const hasMultipleTypes =
result.tracks.length > 0 && (result.albums.length > 0 || result.artists.length > 0);
if (hasMultipleTypes) {
currentTrackElement.textContent = 'Adding to library...';
const importResults = await importToLibrary(result, db, (progress) => {
if (progress.action === 'playlist') {
currentTrackElement.textContent = `Creating playlist: ${progress.item}`;
} else {
currentTrackElement.textContent = `Adding ${progress.action}: ${progress.item}`;
}
});
console.log('Import results:', importResults);
const summary = [];
if (importResults.tracks.added > 0)
summary.push(`${importResults.tracks.added} tracks`);
if (importResults.albums.added > 0)
summary.push(`${importResults.albums.added} albums`);
if (importResults.artists.added > 0)
summary.push(`${importResults.artists.added} artists`);
if (importResults.playlists.created > 0)
summary.push(`${importResults.playlists.created} playlists`);
alert(
`Imported to library:\n${summary.join(', ')}\n\n${
result.missingItems.length > 0
? `${result.missingItems.length} items could not be found.`
: ''
}`
);
progressElement.style.display = 'none';
return;
}
tracks = result.tracks;
const missingTracks = result.missingTracks;
const missingTracks = result.missingItems.filter((i) => i.type === 'track');
if (tracks.length === 0) {
alert('No valid tracks found in the CSV file! Please check the format.');

View file

@ -105,11 +105,336 @@ export function generateXML(playlist, tracks) {
* @param {Function} onProgress - Progress callback
* @returns {Promise<{tracks: Array, missingTracks: Array}>}
*/
export async function parseCSV(csvText, api, onProgress) {
const lines = csvText.trim().split('\n');
if (lines.length < 2) return { tracks: [], missingTracks: [] };
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) {
return header
.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) {
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 === 'track' || typeValue === 'favorite' || typeValue === 'favorite track') 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 (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] : '';
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 = searchResult.items[0];
}
}
if (foundTrack) {
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) {
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) {
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,
},
};
}
export async function importToLibrary(csvResult, db, onProgress) {
const results = {
tracks: { added: 0, failed: 0 },
albums: { added: 0, failed: 0 },
artists: { added: 0, failed: 0 },
playlists: { created: 0, tracksAdded: 0 },
};
const addedTrackIds = new Set();
const addedAlbumIds = new Set();
const addedArtistIds = new Set();
for (const track of csvResult.tracks) {
if (!addedTrackIds.has(track.id)) {
try {
await db.toggleFavorite('track', track);
addedTrackIds.add(track.id);
results.tracks.added++;
} catch {
results.tracks.failed++;
}
}
if (onProgress) onProgress({ action: 'track', item: track.title });
}
for (const album of csvResult.albums) {
if (!addedAlbumIds.has(album.id)) {
try {
await db.toggleFavorite('album', album);
addedAlbumIds.add(album.id);
results.albums.added++;
} catch {
results.albums.failed++;
}
}
if (onProgress) onProgress({ action: 'album', item: album.title });
}
for (const artist of csvResult.artists) {
if (!addedArtistIds.has(artist.id)) {
try {
await db.toggleFavorite('artist', artist);
addedArtistIds.add(artist.id);
results.artists.added++;
} catch {
results.artists.failed++;
}
}
if (onProgress) onProgress({ action: 'artist', item: artist.name });
}
for (const [playlistName, playlistTracks] of Object.entries(csvResult.playlists)) {
if (playlistTracks.length > 0) {
try {
await db.createPlaylist(playlistName, playlistTracks);
results.playlists.created++;
results.playlists.tracksAdded += playlistTracks.length;
} catch {
console.warn(`Failed to create playlist: ${playlistName}`);
}
}
if (onProgress) onProgress({ action: 'playlist', item: playlistName });
}
return results;
}
export async function parseCSV(csvText, api, onProgress) {
const lines = csvText.trim().split('\n');
if (lines.length < 2) return { tracks: [], missingTracks: [] };
// Robust CSV line parser that respects quotes
const parseLine = (text) => {
const values = [];
let current = '';
@ -129,7 +454,6 @@ export async function parseCSV(csvText, api, onProgress) {
}
values.push(current);
// Clean up quotes: remove surrounding quotes and unescape double quotes if any
return values.map((v) => v.trim().replace(/^"|"$/g, '').replace(/""/g, '"').trim());
};
@ -185,7 +509,6 @@ export async function parseCSV(csvText, api, onProgress) {
});
}
// Search for the track
if (trackTitle && artistNames) {
await new Promise((resolve) => setTimeout(resolve, 300));