more imports
This commit is contained in:
parent
a034374859
commit
830155c14a
4 changed files with 847 additions and 473 deletions
93
index.html
93
index.html
|
|
@ -476,6 +476,51 @@
|
|||
>
|
||||
JSPF
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="import-tab"
|
||||
data-import-type="xspf"
|
||||
style="
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
opacity: 0.7;
|
||||
"
|
||||
>
|
||||
XSPF
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="import-tab"
|
||||
data-import-type="xml"
|
||||
style="
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
opacity: 0.7;
|
||||
"
|
||||
>
|
||||
XML
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="import-tab"
|
||||
data-import-type="m3u"
|
||||
style="
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
opacity: 0.7;
|
||||
"
|
||||
>
|
||||
M3U
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="csv-import-panel" class="import-panel active">
|
||||
|
|
@ -517,6 +562,54 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div id="xspf-import-panel" class="import-panel" style="display: none">
|
||||
<p style="margin-bottom: 0.5rem; font-size: 0.9rem">Import from XSPF</p>
|
||||
<p style="font-size: 0.8rem; margin: 0">
|
||||
XSPF (XML Shareable Playlist Format) is a standard XML playlist format supported by many
|
||||
media players including VLC, Audacious, and Clementine.
|
||||
</p>
|
||||
<br />
|
||||
<input
|
||||
type="file"
|
||||
id="xspf-file-input"
|
||||
class="btn-secondary"
|
||||
accept=".xspf,.xml"
|
||||
style="width: 100%; margin-bottom: 0.5rem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="xml-import-panel" class="import-panel" style="display: none">
|
||||
<p style="margin-bottom: 0.5rem; font-size: 0.9rem">Import from XML</p>
|
||||
<p style="font-size: 0.8rem; margin: 0">
|
||||
Import playlists from generic XML formats including iTunes XML playlists, Winamp XML, and
|
||||
other custom XML playlist formats.
|
||||
</p>
|
||||
<br />
|
||||
<input
|
||||
type="file"
|
||||
id="xml-file-input"
|
||||
class="btn-secondary"
|
||||
accept=".xml"
|
||||
style="width: 100%; margin-bottom: 0.5rem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id="m3u-import-panel" class="import-panel" style="display: none">
|
||||
<p style="margin-bottom: 0.5rem; font-size: 0.9rem">Import from M3U/M3U8</p>
|
||||
<p style="font-size: 0.8rem; margin: 0">
|
||||
M3U is the most widely supported playlist format. Import from any M3U or M3U8 playlist file.
|
||||
Track information is extracted from the extended M3U format.
|
||||
</p>
|
||||
<br />
|
||||
<input
|
||||
type="file"
|
||||
id="m3u-file-input"
|
||||
class="btn-secondary"
|
||||
accept=".m3u,.m3u8"
|
||||
style="width: 100%; margin-bottom: 0.5rem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 0.8rem; margin: 0">
|
||||
<b>Warning:</b> This feature isn't perfect and is prone to errors! Please check your playlist
|
||||
after to remove any unwanted songs that were added by the system.
|
||||
|
|
|
|||
|
|
@ -530,10 +530,27 @@ export function trackImportJSPF(playlistName, trackCount, missingCount, source)
|
|||
});
|
||||
}
|
||||
|
||||
export function trackExportPlaylist(playlist) {
|
||||
trackEvent('Export Playlist', {
|
||||
playlist_name: playlist?.title || playlist?.name || 'Unknown',
|
||||
track_count: playlist?.tracks?.length || 0,
|
||||
export function trackImportXSPF(playlistName, trackCount, missingCount) {
|
||||
trackEvent('Import XSPF', {
|
||||
playlist_name: playlistName,
|
||||
track_count: trackCount,
|
||||
missing_count: missingCount,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackImportXML(playlistName, trackCount, missingCount) {
|
||||
trackEvent('Import XML', {
|
||||
playlist_name: playlistName,
|
||||
track_count: trackCount,
|
||||
missing_count: missingCount,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackImportM3U(playlistName, trackCount, missingCount) {
|
||||
trackEvent('Import M3U', {
|
||||
playlist_name: playlistName,
|
||||
track_count: trackCount,
|
||||
missing_count: missingCount,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
708
js/app.js
708
js/app.js
|
|
@ -29,6 +29,10 @@ import {
|
|||
trackCreatePlaylist,
|
||||
trackCreateFolder,
|
||||
trackImportJSPF,
|
||||
trackImportCSV,
|
||||
trackImportXSPF,
|
||||
trackImportXML,
|
||||
trackImportM3U,
|
||||
trackSelectLocalFolder,
|
||||
trackChangeLocalFolder,
|
||||
trackOpenModal,
|
||||
|
|
@ -41,6 +45,7 @@ import {
|
|||
trackOpenLyrics,
|
||||
trackCloseLyrics,
|
||||
} from './analytics.js';
|
||||
import { parseCSV, parseJSPF, parseXSPF, parseXML, parseM3U } from './playlist-importer.js';
|
||||
|
||||
// Lazy-loaded modules
|
||||
let settingsModule = null;
|
||||
|
|
@ -502,13 +507,21 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
// Show/hide panels
|
||||
document.getElementById('csv-import-panel').style.display = importType === 'csv' ? 'block' : 'none';
|
||||
document.getElementById('jspf-import-panel').style.display = importType === 'jspf' ? 'block' : 'none';
|
||||
document.getElementById('xspf-import-panel').style.display = importType === 'xspf' ? 'block' : 'none';
|
||||
document.getElementById('xml-import-panel').style.display = importType === 'xml' ? 'block' : 'none';
|
||||
document.getElementById('m3u-import-panel').style.display = importType === 'm3u' ? 'block' : 'none';
|
||||
|
||||
// Clear the other file input
|
||||
if (importType === 'csv') {
|
||||
document.getElementById('jspf-file-input').value = '';
|
||||
} else {
|
||||
document.getElementById('csv-file-input').value = '';
|
||||
}
|
||||
// Clear all file inputs except the active one
|
||||
document.getElementById('csv-file-input').value =
|
||||
importType === 'csv' ? document.getElementById('csv-file-input').value : '';
|
||||
document.getElementById('jspf-file-input').value =
|
||||
importType === 'jspf' ? document.getElementById('jspf-file-input').value : '';
|
||||
document.getElementById('xspf-file-input').value =
|
||||
importType === 'xspf' ? document.getElementById('xspf-file-input').value : '';
|
||||
document.getElementById('xml-file-input').value =
|
||||
importType === 'xml' ? document.getElementById('xml-file-input').value : '';
|
||||
document.getElementById('m3u-file-input').value =
|
||||
importType === 'm3u' ? document.getElementById('m3u-file-input').value : '';
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -741,6 +754,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
document.getElementById('import-section').style.display = 'block';
|
||||
document.getElementById('csv-file-input').value = '';
|
||||
document.getElementById('jspf-file-input').value = '';
|
||||
document.getElementById('xspf-file-input').value = '';
|
||||
document.getElementById('xml-file-input').value = '';
|
||||
document.getElementById('m3u-file-input').value = '';
|
||||
|
||||
// Reset import tabs to CSV
|
||||
document.querySelectorAll('.import-tab').forEach((tab) => {
|
||||
|
|
@ -748,6 +764,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
});
|
||||
document.getElementById('csv-import-panel').style.display = 'block';
|
||||
document.getElementById('jspf-import-panel').style.display = 'none';
|
||||
document.getElementById('xspf-import-panel').style.display = 'none';
|
||||
document.getElementById('xml-import-panel').style.display = 'none';
|
||||
document.getElementById('m3u-import-panel').style.display = 'none';
|
||||
|
||||
// Reset Public Toggle
|
||||
const publicToggle = document.getElementById('playlist-public-toggle');
|
||||
|
|
@ -848,23 +867,45 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
// Create
|
||||
const csvFileInput = document.getElementById('csv-file-input');
|
||||
const jspfFileInput = document.getElementById('jspf-file-input');
|
||||
const xspfFileInput = document.getElementById('xspf-file-input');
|
||||
const xmlFileInput = document.getElementById('xml-file-input');
|
||||
const m3uFileInput = document.getElementById('m3u-file-input');
|
||||
let tracks = [];
|
||||
let importSource = 'manual';
|
||||
let cover = document.getElementById('playlist-cover-input').value.trim();
|
||||
|
||||
if (jspfFileInput.files.length > 0) {
|
||||
// Import from JSPF
|
||||
importSource = 'jspf_import';
|
||||
const file = jspfFileInput.files[0];
|
||||
// Helper function for import progress
|
||||
const setupProgressElements = () => {
|
||||
const progressElement = document.getElementById('csv-import-progress');
|
||||
const progressFill = document.getElementById('csv-progress-fill');
|
||||
const progressCurrent = document.getElementById('csv-progress-current');
|
||||
const progressTotal = document.getElementById('csv-progress-total');
|
||||
const currentTrackElement = progressElement.querySelector('.current-track');
|
||||
const currentArtistElement = progressElement.querySelector('.current-artist');
|
||||
return {
|
||||
progressElement,
|
||||
progressFill,
|
||||
progressCurrent,
|
||||
progressTotal,
|
||||
currentTrackElement,
|
||||
currentArtistElement,
|
||||
};
|
||||
};
|
||||
|
||||
if (jspfFileInput.files.length > 0) {
|
||||
// Import from JSPF
|
||||
importSource = 'jspf_import';
|
||||
const file = jspfFileInput.files[0];
|
||||
const {
|
||||
progressElement,
|
||||
progressFill,
|
||||
progressCurrent,
|
||||
progressTotal,
|
||||
currentTrackElement,
|
||||
currentArtistElement,
|
||||
} = setupProgressElements();
|
||||
|
||||
try {
|
||||
// Show progress bar
|
||||
progressElement.style.display = 'block';
|
||||
progressFill.style.width = '0%';
|
||||
progressCurrent.textContent = '0';
|
||||
|
|
@ -921,7 +962,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
jspfCreator
|
||||
);
|
||||
|
||||
// if theres missing songs, warn the user
|
||||
if (missingTracks.length > 0) {
|
||||
setTimeout(() => {
|
||||
showMissingTracksNotification(missingTracks);
|
||||
|
|
@ -933,7 +973,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
progressElement.style.display = 'none';
|
||||
return;
|
||||
} finally {
|
||||
// Hide progress bar
|
||||
setTimeout(() => {
|
||||
progressElement.style.display = 'none';
|
||||
}, 1000);
|
||||
|
|
@ -942,15 +981,16 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
// Import from CSV
|
||||
importSource = 'csv_import';
|
||||
const file = csvFileInput.files[0];
|
||||
const progressElement = document.getElementById('csv-import-progress');
|
||||
const progressFill = document.getElementById('csv-progress-fill');
|
||||
const progressCurrent = document.getElementById('csv-progress-current');
|
||||
const progressTotal = document.getElementById('csv-progress-total');
|
||||
const currentTrackElement = progressElement.querySelector('.current-track');
|
||||
const currentArtistElement = progressElement.querySelector('.current-artist');
|
||||
const {
|
||||
progressElement,
|
||||
progressFill,
|
||||
progressCurrent,
|
||||
progressTotal,
|
||||
currentTrackElement,
|
||||
currentArtistElement,
|
||||
} = setupProgressElements();
|
||||
|
||||
try {
|
||||
// Show progress bar
|
||||
progressElement.style.display = 'block';
|
||||
progressFill.style.width = '0%';
|
||||
progressCurrent.textContent = '0';
|
||||
|
|
@ -981,7 +1021,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
}
|
||||
console.log(`Imported ${tracks.length} tracks from CSV`);
|
||||
|
||||
// if theres missing songs, warn the user
|
||||
trackImportCSV(name || 'Untitled', tracks.length, missingTracks.length);
|
||||
|
||||
if (missingTracks.length > 0) {
|
||||
setTimeout(() => {
|
||||
showMissingTracksNotification(missingTracks);
|
||||
|
|
@ -993,7 +1034,183 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
progressElement.style.display = 'none';
|
||||
return;
|
||||
} finally {
|
||||
// Hide progress bar
|
||||
setTimeout(() => {
|
||||
progressElement.style.display = 'none';
|
||||
}, 1000);
|
||||
}
|
||||
} else if (xspfFileInput.files.length > 0) {
|
||||
// Import from XSPF
|
||||
importSource = 'xspf_import';
|
||||
const file = xspfFileInput.files[0];
|
||||
const {
|
||||
progressElement,
|
||||
progressFill,
|
||||
progressCurrent,
|
||||
progressTotal,
|
||||
currentTrackElement,
|
||||
currentArtistElement,
|
||||
} = setupProgressElements();
|
||||
|
||||
try {
|
||||
progressElement.style.display = 'block';
|
||||
progressFill.style.width = '0%';
|
||||
progressCurrent.textContent = '0';
|
||||
currentTrackElement.textContent = 'Reading XSPF file...';
|
||||
if (currentArtistElement) currentArtistElement.textContent = '';
|
||||
|
||||
const xspfText = await file.text();
|
||||
|
||||
const result = await parseXSPF(xspfText, api, (progress) => {
|
||||
const percentage = progress.total > 0 ? (progress.current / progress.total) * 100 : 0;
|
||||
progressFill.style.width = `${Math.min(percentage, 100)}%`;
|
||||
progressCurrent.textContent = progress.current.toString();
|
||||
progressTotal.textContent = progress.total.toString();
|
||||
currentTrackElement.textContent = progress.currentTrack;
|
||||
if (currentArtistElement)
|
||||
currentArtistElement.textContent = progress.currentArtist || '';
|
||||
});
|
||||
|
||||
tracks = result.tracks;
|
||||
const missingTracks = result.missingTracks;
|
||||
|
||||
if (tracks.length === 0) {
|
||||
alert('No valid tracks found in the XSPF file! Please check the format.');
|
||||
progressElement.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
console.log(`Imported ${tracks.length} tracks from XSPF`);
|
||||
|
||||
trackImportXSPF(name || 'Untitled', tracks.length, missingTracks.length);
|
||||
|
||||
if (missingTracks.length > 0) {
|
||||
setTimeout(() => {
|
||||
showMissingTracksNotification(missingTracks);
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse XSPF!', error);
|
||||
alert('Failed to parse XSPF file! ' + error.message);
|
||||
progressElement.style.display = 'none';
|
||||
return;
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
progressElement.style.display = 'none';
|
||||
}, 1000);
|
||||
}
|
||||
} else if (xmlFileInput.files.length > 0) {
|
||||
// Import from XML
|
||||
importSource = 'xml_import';
|
||||
const file = xmlFileInput.files[0];
|
||||
const {
|
||||
progressElement,
|
||||
progressFill,
|
||||
progressCurrent,
|
||||
progressTotal,
|
||||
currentTrackElement,
|
||||
currentArtistElement,
|
||||
} = setupProgressElements();
|
||||
|
||||
try {
|
||||
progressElement.style.display = 'block';
|
||||
progressFill.style.width = '0%';
|
||||
progressCurrent.textContent = '0';
|
||||
currentTrackElement.textContent = 'Reading XML file...';
|
||||
if (currentArtistElement) currentArtistElement.textContent = '';
|
||||
|
||||
const xmlText = await file.text();
|
||||
|
||||
const result = await parseXML(xmlText, api, (progress) => {
|
||||
const percentage = progress.total > 0 ? (progress.current / progress.total) * 100 : 0;
|
||||
progressFill.style.width = `${Math.min(percentage, 100)}%`;
|
||||
progressCurrent.textContent = progress.current.toString();
|
||||
progressTotal.textContent = progress.total.toString();
|
||||
currentTrackElement.textContent = progress.currentTrack;
|
||||
if (currentArtistElement)
|
||||
currentArtistElement.textContent = progress.currentArtist || '';
|
||||
});
|
||||
|
||||
tracks = result.tracks;
|
||||
const missingTracks = result.missingTracks;
|
||||
|
||||
if (tracks.length === 0) {
|
||||
alert('No valid tracks found in the XML file! Please check the format.');
|
||||
progressElement.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
console.log(`Imported ${tracks.length} tracks from XML`);
|
||||
|
||||
trackImportXML(name || 'Untitled', tracks.length, missingTracks.length);
|
||||
|
||||
if (missingTracks.length > 0) {
|
||||
setTimeout(() => {
|
||||
showMissingTracksNotification(missingTracks);
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse XML!', error);
|
||||
alert('Failed to parse XML file! ' + error.message);
|
||||
progressElement.style.display = 'none';
|
||||
return;
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
progressElement.style.display = 'none';
|
||||
}, 1000);
|
||||
}
|
||||
} else if (m3uFileInput.files.length > 0) {
|
||||
// Import from M3U/M3U8
|
||||
importSource = 'm3u_import';
|
||||
const file = m3uFileInput.files[0];
|
||||
const {
|
||||
progressElement,
|
||||
progressFill,
|
||||
progressCurrent,
|
||||
progressTotal,
|
||||
currentTrackElement,
|
||||
currentArtistElement,
|
||||
} = setupProgressElements();
|
||||
|
||||
try {
|
||||
progressElement.style.display = 'block';
|
||||
progressFill.style.width = '0%';
|
||||
progressCurrent.textContent = '0';
|
||||
currentTrackElement.textContent = 'Reading M3U file...';
|
||||
if (currentArtistElement) currentArtistElement.textContent = '';
|
||||
|
||||
const m3uText = await file.text();
|
||||
|
||||
const result = await parseM3U(m3uText, api, (progress) => {
|
||||
const percentage = progress.total > 0 ? (progress.current / progress.total) * 100 : 0;
|
||||
progressFill.style.width = `${Math.min(percentage, 100)}%`;
|
||||
progressCurrent.textContent = progress.current.toString();
|
||||
progressTotal.textContent = progress.total.toString();
|
||||
currentTrackElement.textContent = progress.currentTrack;
|
||||
if (currentArtistElement)
|
||||
currentArtistElement.textContent = progress.currentArtist || '';
|
||||
});
|
||||
|
||||
tracks = result.tracks;
|
||||
const missingTracks = result.missingTracks;
|
||||
|
||||
if (tracks.length === 0) {
|
||||
alert('No valid tracks found in the M3U file! Please check the format.');
|
||||
progressElement.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
console.log(`Imported ${tracks.length} tracks from M3U`);
|
||||
|
||||
trackImportM3U(name || 'Untitled', tracks.length, missingTracks.length);
|
||||
|
||||
if (missingTracks.length > 0) {
|
||||
setTimeout(() => {
|
||||
showMissingTracksNotification(missingTracks);
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse M3U!', error);
|
||||
alert('Failed to parse M3U file! ' + error.message);
|
||||
progressElement.style.display = 'none';
|
||||
return;
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
progressElement.style.display = 'none';
|
||||
}, 1000);
|
||||
|
|
@ -1789,453 +2006,6 @@ function showMissingTracksNotification(missingTracks) {
|
|||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
async function parseCSV(csvText, api, onProgress) {
|
||||
const lines = csvText.trim().split('\n');
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
// Robust CSV line parser that respects quotes
|
||||
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);
|
||||
|
||||
// Clean up quotes: remove surrounding quotes and unescape double quotes if any
|
||||
return values.map((v) => v.trim().replace(/^"|"$/g, '').replace(/""/g, '"').trim());
|
||||
};
|
||||
|
||||
const headers = parseLine(lines[0]);
|
||||
const rows = lines.slice(1);
|
||||
|
||||
const tracks = [];
|
||||
const missingTracks = [];
|
||||
const totalTracks = rows.length;
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
if (!row.trim()) continue; // Skip empty lines
|
||||
|
||||
const values = parseLine(row);
|
||||
|
||||
if (values.length >= headers.length) {
|
||||
let trackTitle = '';
|
||||
let artistNames = '';
|
||||
let albumName = '';
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
const value = values[index];
|
||||
if (!value) return;
|
||||
|
||||
switch (header.toLowerCase()) {
|
||||
case 'track name':
|
||||
case 'title':
|
||||
case 'song':
|
||||
trackTitle = value;
|
||||
break;
|
||||
case 'artist name(s)':
|
||||
case 'artist name':
|
||||
case 'artist':
|
||||
case 'artists':
|
||||
artistNames = value;
|
||||
break;
|
||||
case 'album':
|
||||
case 'album name':
|
||||
albumName = value;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
current: i,
|
||||
total: totalTracks,
|
||||
currentTrack: trackTitle || 'Unknown track',
|
||||
currentArtist: artistNames || '',
|
||||
});
|
||||
}
|
||||
|
||||
// Search for the track in hifi tidal api's catalog
|
||||
if (trackTitle && artistNames) {
|
||||
// Add a small delay to prevent rate limiting
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
try {
|
||||
let foundTrack = null;
|
||||
|
||||
// Helper: Normalize strings for fuzzy matching
|
||||
const normalize = (str) =>
|
||||
str
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
// Helper: Check if result matches our criteria
|
||||
const isValidMatch = (track, title, artists, album) => {
|
||||
if (!track) return false;
|
||||
|
||||
const trackTitle = normalize(track.title || '');
|
||||
const trackArtists = (track.artists || []).map((a) => normalize(a.name || '')).join(' ');
|
||||
const trackAlbum = normalize(track.album?.name || '');
|
||||
|
||||
const queryTitle = normalize(title);
|
||||
const queryArtists = normalize(artists);
|
||||
const queryAlbum = normalize(album || '');
|
||||
|
||||
// Must match title (exact or substring match)
|
||||
const titleMatch =
|
||||
trackTitle === queryTitle ||
|
||||
trackTitle.includes(queryTitle) ||
|
||||
queryTitle.includes(trackTitle);
|
||||
if (!titleMatch) return false;
|
||||
|
||||
// Must match at least one artist
|
||||
const artistMatch =
|
||||
trackArtists.includes(queryArtists.split(' ')[0]) ||
|
||||
queryArtists.includes(trackArtists.split(' ')[0]);
|
||||
if (!artistMatch) return false;
|
||||
|
||||
// If album provided, prefer matching album but not strict
|
||||
if (queryAlbum) {
|
||||
const albumMatch =
|
||||
trackAlbum === queryAlbum ||
|
||||
trackAlbum.includes(queryAlbum) ||
|
||||
queryAlbum.includes(trackAlbum);
|
||||
return albumMatch;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 1. Initial Search: Title + All Artists + Album (most specific)
|
||||
if (!foundTrack) {
|
||||
let searchQuery = `${trackTitle} ${artistNames}`;
|
||||
if (albumName) searchQuery += ` ${albumName}`;
|
||||
const searchResults = await api.searchTracks(searchQuery);
|
||||
|
||||
if (searchResults.items && searchResults.items.length > 0) {
|
||||
// Try to find best match within results
|
||||
for (const result of searchResults.items) {
|
||||
if (isValidMatch(result, trackTitle, artistNames, albumName)) {
|
||||
foundTrack = result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fallback: if no valid match found, use first result only if album matches
|
||||
if (!foundTrack && albumName) {
|
||||
const firstResult = searchResults.items[0];
|
||||
if (isValidMatch(firstResult, trackTitle, artistNames, albumName)) {
|
||||
foundTrack = firstResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Retry: Title + Main Artist + Album
|
||||
if (!foundTrack && artistNames) {
|
||||
const mainArtist = artistNames.split(',')[0].trim();
|
||||
if (mainArtist && mainArtist !== artistNames) {
|
||||
let searchQuery = `${trackTitle} ${mainArtist}`;
|
||||
if (albumName) searchQuery += ` ${albumName}`;
|
||||
const searchResults = await api.searchTracks(searchQuery);
|
||||
|
||||
if (searchResults.items && searchResults.items.length > 0) {
|
||||
for (const result of searchResults.items) {
|
||||
if (isValidMatch(result, trackTitle, mainArtist, albumName)) {
|
||||
foundTrack = result;
|
||||
console.log(`Found (Retry 1 - Main Artist): ${trackTitle}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Retry: Just Title + Album (strong album context)
|
||||
if (!foundTrack && albumName) {
|
||||
const searchQuery = `${trackTitle} ${albumName}`;
|
||||
const searchResults = await api.searchTracks(searchQuery);
|
||||
|
||||
if (searchResults.items && searchResults.items.length > 0) {
|
||||
for (const result of searchResults.items) {
|
||||
if (isValidMatch(result, trackTitle, artistNames, albumName)) {
|
||||
foundTrack = result;
|
||||
console.log(`Found (Retry 2 - Album): ${trackTitle}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean title for retry strategies
|
||||
// Remove " - ", "(feat. ...)", "[feat. ...]"
|
||||
const cleanTitle = (t) =>
|
||||
t
|
||||
.split(' - ')[0]
|
||||
.replace(/\s*[([]feat\.?.*?[)\]]/i, '')
|
||||
.trim();
|
||||
const cleanedTitle = cleanTitle(trackTitle);
|
||||
const isTitleCleaned = cleanedTitle !== trackTitle;
|
||||
|
||||
// 4. Retry: Cleaned Title + Main Artist + Album
|
||||
if (!foundTrack && isTitleCleaned) {
|
||||
const mainArtist = (artistNames || '').split(',')[0].trim();
|
||||
if (cleanedTitle) {
|
||||
let searchQuery = `${cleanedTitle} ${mainArtist}`;
|
||||
if (albumName) searchQuery += ` ${albumName}`;
|
||||
const searchResults = await api.searchTracks(searchQuery);
|
||||
|
||||
if (searchResults.items && searchResults.items.length > 0) {
|
||||
for (const result of searchResults.items) {
|
||||
if (isValidMatch(result, cleanedTitle, mainArtist, albumName)) {
|
||||
foundTrack = result;
|
||||
console.log(`Found (Retry 3 - Cleaned Title): ${trackTitle}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Retry: Title + Main Artist (Ignore Album in Query and Match)
|
||||
if (!foundTrack) {
|
||||
const mainArtist = (artistNames || '').split(',')[0].trim();
|
||||
// Search WITHOUT album name to find tracks where album metadata differs
|
||||
const searchQuery = `${trackTitle} ${mainArtist}`;
|
||||
const searchResults = await api.searchTracks(searchQuery);
|
||||
|
||||
if (searchResults.items && searchResults.items.length > 0) {
|
||||
for (const result of searchResults.items) {
|
||||
// Pass null for album to ignore it in validation
|
||||
if (isValidMatch(result, trackTitle, mainArtist, null)) {
|
||||
foundTrack = result;
|
||||
console.log(`Found (Retry 4 - Ignore Album): ${trackTitle}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Retry: Cleaned Title + Main Artist (Ignore Album in Query and Match)
|
||||
if (!foundTrack && isTitleCleaned) {
|
||||
const mainArtist = (artistNames || '').split(',')[0].trim();
|
||||
const searchQuery = `${cleanedTitle} ${mainArtist}`;
|
||||
const searchResults = await api.searchTracks(searchQuery);
|
||||
|
||||
if (searchResults.items && searchResults.items.length > 0) {
|
||||
for (const result of searchResults.items) {
|
||||
if (isValidMatch(result, cleanedTitle, mainArtist, null)) {
|
||||
foundTrack = result;
|
||||
console.log(`Found (Retry 5 - Cleaned Title + Ignore Album): ${trackTitle}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundTrack) {
|
||||
tracks.push(foundTrack);
|
||||
console.log(`✓ "${trackTitle}" by ${artistNames}${albumName ? ' [' + albumName + ']' : ''}`);
|
||||
} else {
|
||||
console.warn(
|
||||
`✗ Track not found: "${trackTitle}" by ${artistNames}${albumName ? ' [' + albumName + ']' : ''}`
|
||||
);
|
||||
missingTracks.push(
|
||||
`${trackTitle} - ${artistNames}${albumName ? ' (album: ' + albumName + ')' : ''}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error searching for track "${trackTitle}":`, error);
|
||||
missingTracks.push(
|
||||
`${trackTitle} - ${artistNames}${albumName ? ' (album: ' + albumName + ')' : ''}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// yayyy its finished :P
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
current: totalTracks,
|
||||
total: totalTracks,
|
||||
currentTrack: 'Import complete',
|
||||
});
|
||||
}
|
||||
|
||||
return { tracks, missingTracks };
|
||||
}
|
||||
|
||||
async function parseJSPF(jspfText, api, onProgress) {
|
||||
try {
|
||||
const jspfData = JSON.parse(jspfText);
|
||||
|
||||
if (!jspfData.playlist || !Array.isArray(jspfData.playlist.track)) {
|
||||
throw new Error('Invalid JSPF format: missing playlist or track array');
|
||||
}
|
||||
|
||||
const playlist = jspfData.playlist;
|
||||
const tracks = [];
|
||||
const missingTracks = [];
|
||||
const totalTracks = playlist.track.length;
|
||||
|
||||
// Helper: Normalize strings for fuzzy matching
|
||||
const normalize = (str) =>
|
||||
str
|
||||
?.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim() || '';
|
||||
|
||||
// Helper: Check if result matches our criteria
|
||||
const isValidMatch = (track, title, artists, album) => {
|
||||
if (!track) return false;
|
||||
|
||||
const trackTitle = normalize(track.title || '');
|
||||
const trackArtists = (track.artists || []).map((a) => normalize(a.name || '')).join(' ');
|
||||
const trackAlbum = normalize(track.album?.name || '');
|
||||
|
||||
const queryTitle = normalize(title);
|
||||
const queryArtists = normalize(artists);
|
||||
const queryAlbum = normalize(album || '');
|
||||
|
||||
// Must match title (exact or substring match)
|
||||
const titleMatch =
|
||||
trackTitle === queryTitle || trackTitle.includes(queryTitle) || queryTitle.includes(trackTitle);
|
||||
if (!titleMatch) return false;
|
||||
|
||||
// Must match at least one artist
|
||||
const artistMatch =
|
||||
trackArtists.includes(queryArtists.split(' ')[0]) || queryArtists.includes(trackArtists.split(' ')[0]);
|
||||
if (!artistMatch) return false;
|
||||
|
||||
// If album provided, prefer matching album but not strict
|
||||
if (queryAlbum) {
|
||||
const albumMatch =
|
||||
trackAlbum === queryAlbum || trackAlbum.includes(queryAlbum) || queryAlbum.includes(trackAlbum);
|
||||
return albumMatch;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
for (let i = 0; i < playlist.track.length; i++) {
|
||||
const jspfTrack = playlist.track[i];
|
||||
const trackTitle = jspfTrack.title;
|
||||
const trackCreator = jspfTrack.creator;
|
||||
const trackAlbum = jspfTrack.album;
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
current: i,
|
||||
total: totalTracks,
|
||||
currentTrack: trackTitle || 'Unknown track',
|
||||
currentArtist: trackCreator || '',
|
||||
});
|
||||
}
|
||||
|
||||
// Try to find track
|
||||
let foundTrack = null;
|
||||
|
||||
if (trackTitle && trackCreator) {
|
||||
// Add delay to prevent rate limiting
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
try {
|
||||
// 1. Search with title + artist + album
|
||||
let searchQuery = `${trackTitle} ${trackCreator}`;
|
||||
if (trackAlbum) searchQuery += ` ${trackAlbum}`;
|
||||
const searchResults = await api.searchTracks(searchQuery);
|
||||
|
||||
if (searchResults.items && searchResults.items.length > 0) {
|
||||
for (const result of searchResults.items) {
|
||||
if (isValidMatch(result, trackTitle, trackCreator, trackAlbum)) {
|
||||
foundTrack = result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Retry with main artist only
|
||||
if (!foundTrack) {
|
||||
const mainArtist = trackCreator.split(',')[0].trim();
|
||||
if (mainArtist && mainArtist !== trackCreator) {
|
||||
const searchResults = await api.searchTracks(`${trackTitle} ${mainArtist}`);
|
||||
if (searchResults.items) {
|
||||
for (const result of searchResults.items) {
|
||||
if (isValidMatch(result, trackTitle, mainArtist, trackAlbum)) {
|
||||
foundTrack = result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try just title + artist, ignoring album
|
||||
if (!foundTrack) {
|
||||
const searchResults = await api.searchTracks(`${trackTitle} ${trackCreator}`);
|
||||
if (searchResults.items) {
|
||||
for (const result of searchResults.items) {
|
||||
if (isValidMatch(result, trackTitle, trackCreator, null)) {
|
||||
foundTrack = result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundTrack) {
|
||||
tracks.push(foundTrack);
|
||||
console.log(`✓ "${trackTitle}" by ${trackCreator}`);
|
||||
} else {
|
||||
console.warn(`✗ Track not found: "${trackTitle}" by ${trackCreator}`);
|
||||
missingTracks.push(
|
||||
`${trackTitle} - ${trackCreator}${trackAlbum ? ' (' + trackAlbum + ')' : ''}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error searching for track "${trackTitle}":`, error);
|
||||
missingTracks.push(`${trackTitle} - ${trackCreator}${trackAlbum ? ' (' + trackAlbum + ')' : ''}`);
|
||||
}
|
||||
} else {
|
||||
missingTracks.push(`Invalid track entry at position ${i + 1}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Final progress update
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
current: totalTracks,
|
||||
total: totalTracks,
|
||||
currentTrack: 'Import complete',
|
||||
});
|
||||
}
|
||||
|
||||
return { tracks, missingTracks, jspfData };
|
||||
} catch (error) {
|
||||
console.error('JSPF parsing error:', error);
|
||||
throw new Error('Failed to parse JSPF file: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showDiscographyDownloadModal(artist, api, quality, lyricsManager, triggerBtn) {
|
||||
const modal = document.getElementById('discography-download-modal');
|
||||
|
||||
|
|
|
|||
494
js/playlist-importer.js
Normal file
494
js/playlist-importer.js
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
import { sanitizeForFilename } from './utils.js';
|
||||
|
||||
/**
|
||||
* 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 = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||
xml += '<playlist xmlns="http://xspf.org/ns/0/" version="1">\n';
|
||||
xml += ` <title>${escapeXml(playlist.title || 'Unknown Playlist')}</title>\n`;
|
||||
xml += ` <creator>${escapeXml(playlist.artist || 'Various Artists')}</creator>\n`;
|
||||
xml += ` <date>${date}</date>\n`;
|
||||
xml += ' <trackList>\n';
|
||||
|
||||
tracks.forEach((track) => {
|
||||
xml += ' <track>\n';
|
||||
xml += ` <title>${escapeXml(track.title || 'Unknown Title')}</title>\n`;
|
||||
xml += ` <creator>${escapeXml(getTrackArtists(track))}</creator>\n`;
|
||||
if (track.album?.title) {
|
||||
xml += ` <album>${escapeXml(track.album.title)}</album>\n`;
|
||||
}
|
||||
if (track.duration) {
|
||||
xml += ` <duration>${Math.round(track.duration * 1000)}</duration>\n`;
|
||||
}
|
||||
xml += ' </track>\n';
|
||||
});
|
||||
|
||||
xml += ' </trackList>\n';
|
||||
xml += '</playlist>\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 = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||
xml += '<playlist>\n';
|
||||
xml += ` <name>${escapeXml(playlist.title || 'Unknown Playlist')}</name>\n`;
|
||||
xml += ` <creator>${escapeXml(playlist.artist || 'Various Artists')}</creator>\n`;
|
||||
xml += ` <created>${date}</created>\n`;
|
||||
xml += ` <trackCount>${tracks.length}</trackCount>\n`;
|
||||
xml += ' <tracks>\n';
|
||||
|
||||
tracks.forEach((track, index) => {
|
||||
xml += ' <track>\n';
|
||||
xml += ` <position>${index + 1}</position>\n`;
|
||||
xml += ` <title>${escapeXml(track.title || '')}</title>\n`;
|
||||
xml += ` <artist>${escapeXml(getTrackArtists(track) || '')}</artist>\n`;
|
||||
xml += ` <album>${escapeXml(track.album?.title || '')}</album>\n`;
|
||||
xml += ` <duration>${Math.round(track.duration || 0)}</duration>\n`;
|
||||
xml += ' </track>\n';
|
||||
});
|
||||
|
||||
xml += ' </tracks>\n';
|
||||
xml += '</playlist>\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}>}
|
||||
*/
|
||||
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 = '';
|
||||
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);
|
||||
|
||||
// Clean up quotes: remove surrounding quotes and unescape double quotes if any
|
||||
return values.map((v) => v.trim().replace(/^"|"$/g, '').replace(/""/g, '"').trim());
|
||||
};
|
||||
|
||||
const headers = parseLine(lines[0]);
|
||||
const rows = lines.slice(1);
|
||||
|
||||
const tracks = [];
|
||||
const missingTracks = [];
|
||||
const totalTracks = rows.length;
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
if (!row.trim()) continue;
|
||||
|
||||
const values = parseLine(row);
|
||||
|
||||
if (values.length >= headers.length) {
|
||||
let trackTitle = '';
|
||||
let artistNames = '';
|
||||
let albumName = '';
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
const value = values[index];
|
||||
if (!value) return;
|
||||
|
||||
switch (header.toLowerCase()) {
|
||||
case 'track name':
|
||||
case 'title':
|
||||
case 'song':
|
||||
case 'name':
|
||||
trackTitle = value;
|
||||
break;
|
||||
case 'artist name(s)':
|
||||
case 'artist name':
|
||||
case 'artist':
|
||||
case 'artists':
|
||||
case 'creator':
|
||||
artistNames = value;
|
||||
break;
|
||||
case 'album':
|
||||
case 'album name':
|
||||
albumName = value;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
current: i,
|
||||
total: totalTracks,
|
||||
currentTrack: trackTitle || 'Unknown track',
|
||||
currentArtist: artistNames || '',
|
||||
});
|
||||
}
|
||||
|
||||
// Search for the track
|
||||
if (trackTitle && artistNames) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
try {
|
||||
const searchQuery = `"${trackTitle}" ${artistNames}`.trim();
|
||||
const searchResult = await api.searchTracks(searchQuery);
|
||||
|
||||
if (searchResult.items && searchResult.items.length > 0) {
|
||||
tracks.push(searchResult.items[0]);
|
||||
} else {
|
||||
missingTracks.push({ title: trackTitle, artist: artistNames, album: albumName });
|
||||
}
|
||||
} catch (e) {
|
||||
missingTracks.push({ title: trackTitle, artist: artistNames, album: albumName });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tracks, missingTracks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses JSPF (JSON Shareable Playlist Format)
|
||||
* @param {string} jspfText - JSPF JSON content
|
||||
* @param {Function} api - API instance for searching tracks
|
||||
* @param {Function} onProgress - Progress callback
|
||||
* @returns {Promise<{tracks: Array, missingTracks: Array}>}
|
||||
*/
|
||||
export async function parseJSPF(jspfText, api, onProgress) {
|
||||
try {
|
||||
const jspfData = JSON.parse(jspfText);
|
||||
|
||||
if (!jspfData.playlist || !Array.isArray(jspfData.playlist.track)) {
|
||||
throw new Error('Invalid JSPF format: missing playlist or track array');
|
||||
}
|
||||
|
||||
const playlist = jspfData.playlist;
|
||||
const tracks = [];
|
||||
const missingTracks = [];
|
||||
const totalTracks = playlist.track.length;
|
||||
|
||||
for (let i = 0; i < playlist.track.length; i++) {
|
||||
const jspfTrack = playlist.track[i];
|
||||
const trackTitle = jspfTrack.title;
|
||||
const trackCreator = jspfTrack.creator;
|
||||
const trackAlbum = jspfTrack.album;
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
current: i,
|
||||
total: totalTracks,
|
||||
currentTrack: trackTitle || 'Unknown track',
|
||||
currentArtist: trackCreator || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (trackTitle && trackCreator) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
try {
|
||||
const searchQuery = `${trackTitle} ${trackCreator}`;
|
||||
const searchResults = await api.searchTracks(searchQuery);
|
||||
|
||||
if (searchResults.items && searchResults.items.length > 0) {
|
||||
tracks.push(searchResults.items[0]);
|
||||
} else {
|
||||
missingTracks.push({ title: trackTitle, artist: trackCreator, album: trackAlbum });
|
||||
}
|
||||
} catch (e) {
|
||||
missingTracks.push({ title: trackTitle, artist: trackCreator, album: trackAlbum });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tracks, missingTracks };
|
||||
} catch (error) {
|
||||
throw new Error('Failed to parse JSPF: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses XSPF (XML Shareable Playlist Format)
|
||||
* @param {string} xspfText - XSPF XML content
|
||||
* @param {Function} api - API instance for searching tracks
|
||||
* @param {Function} onProgress - Progress callback
|
||||
* @returns {Promise<{tracks: Array, missingTracks: Array}>}
|
||||
*/
|
||||
export async function parseXSPF(xspfText, api, onProgress) {
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(xspfText, 'text/xml');
|
||||
|
||||
const trackList = xmlDoc.getElementsByTagName('track');
|
||||
const tracks = [];
|
||||
const missingTracks = [];
|
||||
const totalTracks = trackList.length;
|
||||
|
||||
for (let i = 0; i < trackList.length; i++) {
|
||||
const trackEl = trackList[i];
|
||||
const title = trackEl.getElementsByTagName('title')[0]?.textContent || '';
|
||||
const creator = trackEl.getElementsByTagName('creator')[0]?.textContent || '';
|
||||
const album = trackEl.getElementsByTagName('album')[0]?.textContent || '';
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
current: i,
|
||||
total: totalTracks,
|
||||
currentTrack: title || 'Unknown track',
|
||||
currentArtist: creator || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (title && creator) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
try {
|
||||
const searchQuery = `${title} ${creator}`;
|
||||
const searchResults = await api.searchTracks(searchQuery);
|
||||
|
||||
if (searchResults.items && searchResults.items.length > 0) {
|
||||
tracks.push(searchResults.items[0]);
|
||||
} else {
|
||||
missingTracks.push({ title, artist: creator, album });
|
||||
}
|
||||
} catch (e) {
|
||||
missingTracks.push({ title, artist: creator, album });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tracks, missingTracks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses generic XML playlist format
|
||||
* @param {string} xmlText - XML content
|
||||
* @param {Function} api - API instance for searching tracks
|
||||
* @param {Function} onProgress - Progress callback
|
||||
* @returns {Promise<{tracks: Array, missingTracks: Array}>}
|
||||
*/
|
||||
export async function parseXML(xmlText, api, onProgress) {
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
|
||||
|
||||
// Try different track element names
|
||||
let trackElements = xmlDoc.getElementsByTagName('track');
|
||||
if (trackElements.length === 0) {
|
||||
trackElements = xmlDoc.getElementsByTagName('song');
|
||||
}
|
||||
if (trackElements.length === 0) {
|
||||
trackElements = xmlDoc.getElementsByTagName('item');
|
||||
}
|
||||
|
||||
const tracks = [];
|
||||
const missingTracks = [];
|
||||
const totalTracks = trackElements.length;
|
||||
|
||||
for (let i = 0; i < trackElements.length; i++) {
|
||||
const trackEl = trackElements[i];
|
||||
|
||||
// Try different element names for title/artist
|
||||
const title =
|
||||
trackEl.getElementsByTagName('title')[0]?.textContent ||
|
||||
trackEl.getElementsByTagName('name')[0]?.textContent ||
|
||||
'';
|
||||
const artist =
|
||||
trackEl.getElementsByTagName('artist')[0]?.textContent ||
|
||||
trackEl.getElementsByTagName('creator')[0]?.textContent ||
|
||||
trackEl.getElementsByTagName('performer')[0]?.textContent ||
|
||||
'';
|
||||
const album = trackEl.getElementsByTagName('album')[0]?.textContent || '';
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
current: i,
|
||||
total: totalTracks,
|
||||
currentTrack: title || 'Unknown track',
|
||||
currentArtist: artist || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (title && artist) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
try {
|
||||
const searchQuery = `${title} ${artist}`;
|
||||
const searchResults = await api.searchTracks(searchQuery);
|
||||
|
||||
if (searchResults.items && searchResults.items.length > 0) {
|
||||
tracks.push(searchResults.items[0]);
|
||||
} else {
|
||||
missingTracks.push({ title, artist, album });
|
||||
}
|
||||
} catch (e) {
|
||||
missingTracks.push({ title, artist, album });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tracks, missingTracks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses M3U/M3U8 playlist format
|
||||
* @param {string} m3uText - M3U content
|
||||
* @param {Function} api - API instance for searching tracks
|
||||
* @param {Function} onProgress - Progress callback
|
||||
* @returns {Promise<{tracks: Array, missingTracks: Array}>}
|
||||
*/
|
||||
export async function parseM3U(m3uText, api, onProgress) {
|
||||
const lines = m3uText.trim().split('\n');
|
||||
const tracks = [];
|
||||
const missingTracks = [];
|
||||
|
||||
const trackInfo = [];
|
||||
let currentInfo = null;
|
||||
|
||||
// Parse M3U format
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#EXTM3U')) continue;
|
||||
|
||||
if (trimmed.startsWith('#EXTINF:')) {
|
||||
// Parse EXTINF line: #EXTINF:duration,Artist - Title
|
||||
const match = trimmed.match(/#EXTINF:(-?\d+)?,(.+)/);
|
||||
if (match) {
|
||||
const displayName = match[2];
|
||||
const parts = displayName.split(' - ');
|
||||
currentInfo = {
|
||||
title: parts.length > 1 ? parts.slice(1).join(' - ') : displayName,
|
||||
artist: parts.length > 1 ? parts[0] : '',
|
||||
};
|
||||
}
|
||||
} else if (!trimmed.startsWith('#')) {
|
||||
// This is a file path line
|
||||
if (currentInfo) {
|
||||
trackInfo.push(currentInfo);
|
||||
currentInfo = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalTracks = trackInfo.length;
|
||||
|
||||
for (let i = 0; i < trackInfo.length; i++) {
|
||||
const info = trackInfo[i];
|
||||
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
current: i,
|
||||
total: totalTracks,
|
||||
currentTrack: info.title || 'Unknown track',
|
||||
currentArtist: info.artist || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (info.title) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
try {
|
||||
const searchQuery = info.artist ? `${info.title} ${info.artist}` : info.title;
|
||||
const searchResults = await api.searchTracks(searchQuery);
|
||||
|
||||
if (searchResults.items && searchResults.items.length > 0) {
|
||||
tracks.push(searchResults.items[0]);
|
||||
} else {
|
||||
missingTracks.push({ title: info.title, artist: info.artist, album: '' });
|
||||
}
|
||||
} catch (e) {
|
||||
missingTracks.push({ title: info.title, artist: info.artist, album: '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tracks, missingTracks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats duration in MM:SS format
|
||||
* @param {number} seconds - Duration in seconds
|
||||
* @returns {string} Formatted duration
|
||||
*/
|
||||
function formatDuration(seconds) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to escape XML special characters
|
||||
*/
|
||||
function escapeXml(text) {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.toString()
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Export all functions
|
||||
export { getTrackArtists };
|
||||
Loading…
Reference in a new issue