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,
|
trackOpenLyrics,
|
||||||
trackCloseLyrics,
|
trackCloseLyrics,
|
||||||
} from './analytics.js';
|
} 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
|
// Lazy-loaded modules
|
||||||
let settingsModule = null;
|
let settingsModule = null;
|
||||||
|
|
@ -1316,8 +1324,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
} else if (csvFileInput.files.length > 0) {
|
} else if (csvFileInput.files.length > 0) {
|
||||||
// Import from CSV
|
|
||||||
importSource = 'csv_import';
|
|
||||||
const file = csvFileInput.files[0];
|
const file = csvFileInput.files[0];
|
||||||
const {
|
const {
|
||||||
progressElement,
|
progressElement,
|
||||||
|
|
@ -1337,20 +1343,60 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
|
||||||
const csvText = await file.text();
|
const csvText = await file.text();
|
||||||
const lines = csvText.trim().split('\n');
|
const lines = csvText.trim().split('\n');
|
||||||
const totalTracks = Math.max(0, lines.length - 1);
|
const totalItems = Math.max(0, lines.length - 1);
|
||||||
progressTotal.textContent = totalTracks.toString();
|
progressTotal.textContent = totalItems.toString();
|
||||||
|
|
||||||
const result = await parseCSV(csvText, api, (progress) => {
|
const result = await parseDynamicCSV(csvText, api, (progress) => {
|
||||||
const percentage = totalTracks > 0 ? (progress.current / totalTracks) * 100 : 0;
|
const percentage = totalItems > 0 ? (progress.current / totalItems) * 100 : 0;
|
||||||
progressFill.style.width = `${Math.min(percentage, 100)}%`;
|
progressFill.style.width = `${Math.min(percentage, 100)}%`;
|
||||||
progressCurrent.textContent = progress.current.toString();
|
progressCurrent.textContent = progress.current.toString();
|
||||||
currentTrackElement.textContent = progress.currentTrack;
|
currentTrackElement.textContent = progress.currentItem;
|
||||||
if (currentArtistElement)
|
if (currentArtistElement) {
|
||||||
currentArtistElement.textContent = progress.currentArtist || '';
|
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;
|
tracks = result.tracks;
|
||||||
const missingTracks = result.missingTracks;
|
const missingTracks = result.missingItems.filter((i) => i.type === 'track');
|
||||||
|
|
||||||
if (tracks.length === 0) {
|
if (tracks.length === 0) {
|
||||||
alert('No valid tracks found in the CSV file! Please check the format.');
|
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
|
* @param {Function} onProgress - Progress callback
|
||||||
* @returns {Promise<{tracks: Array, missingTracks: Array}>}
|
* @returns {Promise<{tracks: Array, missingTracks: Array}>}
|
||||||
*/
|
*/
|
||||||
export async function parseCSV(csvText, api, onProgress) {
|
const HEADER_MAPPINGS = {
|
||||||
const lines = csvText.trim().split('\n');
|
track: ['track name', 'title', 'song', 'name', 'track', 'track title'],
|
||||||
if (lines.length < 2) return { tracks: [], missingTracks: [] };
|
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 parseLine = (text) => {
|
||||||
const values = [];
|
const values = [];
|
||||||
let current = '';
|
let current = '';
|
||||||
|
|
@ -129,7 +454,6 @@ export async function parseCSV(csvText, api, onProgress) {
|
||||||
}
|
}
|
||||||
values.push(current);
|
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());
|
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) {
|
if (trackTitle && artistNames) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue