favourites importing
This commit is contained in:
parent
1aaf2dfd46
commit
fe6b1e9fad
2 changed files with 386 additions and 17 deletions
68
js/app.js
68
js/app.js
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue