it was time to move on...
This commit is contained in:
@@ -1,282 +0,0 @@
|
||||
import { downloadQueue } from './queue.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const pathSegments = window.location.pathname.split('/');
|
||||
const albumId = pathSegments[pathSegments.indexOf('album') + 1];
|
||||
|
||||
if (!albumId) {
|
||||
showError('No album ID provided.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch album info directly
|
||||
fetch(`/api/album/info?id=${encodeURIComponent(albumId)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => renderAlbum(data))
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showError('Failed to load album.');
|
||||
});
|
||||
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => {
|
||||
downloadQueue.toggleVisibility();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function renderAlbum(album) {
|
||||
// Hide loading and error messages.
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
document.getElementById('error').classList.add('hidden');
|
||||
|
||||
// Check if album itself is marked explicit and filter is enabled
|
||||
const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled();
|
||||
if (isExplicitFilterEnabled && album.explicit) {
|
||||
// Show placeholder for explicit album
|
||||
const placeholderContent = `
|
||||
<div class="explicit-filter-placeholder">
|
||||
<h2>Explicit Content Filtered</h2>
|
||||
<p>This album contains explicit content and has been filtered based on your settings.</p>
|
||||
<p>The explicit content filter is controlled by environment variables.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const contentContainer = document.getElementById('album-header');
|
||||
if (contentContainer) {
|
||||
contentContainer.innerHTML = placeholderContent;
|
||||
contentContainer.classList.remove('hidden');
|
||||
}
|
||||
|
||||
return; // Stop rendering the actual album content
|
||||
}
|
||||
|
||||
const baseUrl = window.location.origin;
|
||||
|
||||
// Set album header info.
|
||||
document.getElementById('album-name').innerHTML =
|
||||
`<a href="${baseUrl}/album/${album.id || ''}">${album.name || 'Unknown Album'}</a>`;
|
||||
|
||||
document.getElementById('album-artist').innerHTML =
|
||||
`By ${album.artists?.map(artist =>
|
||||
`<a href="${baseUrl}/artist/${artist?.id || ''}">${artist?.name || 'Unknown Artist'}</a>`
|
||||
).join(', ') || 'Unknown Artist'}`;
|
||||
|
||||
const releaseYear = album.release_date ? new Date(album.release_date).getFullYear() : 'N/A';
|
||||
document.getElementById('album-stats').textContent =
|
||||
`${releaseYear} • ${album.total_tracks || '0'} songs • ${album.label || 'Unknown Label'}`;
|
||||
|
||||
document.getElementById('album-copyright').textContent =
|
||||
album.copyrights?.map(c => c?.text || '').filter(text => text).join(' • ') || '';
|
||||
|
||||
const image = album.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||
document.getElementById('album-image').src = image;
|
||||
|
||||
// Create (if needed) the Home Button.
|
||||
let homeButton = document.getElementById('homeButton');
|
||||
if (!homeButton) {
|
||||
homeButton = document.createElement('button');
|
||||
homeButton.id = 'homeButton';
|
||||
homeButton.className = 'home-btn';
|
||||
|
||||
const homeIcon = document.createElement('img');
|
||||
homeIcon.src = '/static/images/home.svg';
|
||||
homeIcon.alt = 'Home';
|
||||
homeButton.appendChild(homeIcon);
|
||||
|
||||
// Insert as first child of album-header.
|
||||
const headerContainer = document.getElementById('album-header');
|
||||
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
|
||||
}
|
||||
homeButton.addEventListener('click', () => {
|
||||
window.location.href = window.location.origin;
|
||||
});
|
||||
|
||||
// Check if any track in the album is explicit when filter is enabled
|
||||
let hasExplicitTrack = false;
|
||||
if (isExplicitFilterEnabled && album.tracks?.items) {
|
||||
hasExplicitTrack = album.tracks.items.some(track => track && track.explicit);
|
||||
}
|
||||
|
||||
// Create (if needed) the Download Album Button.
|
||||
let downloadAlbumBtn = document.getElementById('downloadAlbumBtn');
|
||||
if (!downloadAlbumBtn) {
|
||||
downloadAlbumBtn = document.createElement('button');
|
||||
downloadAlbumBtn.id = 'downloadAlbumBtn';
|
||||
downloadAlbumBtn.textContent = 'Download Full Album';
|
||||
downloadAlbumBtn.className = 'download-btn download-btn--main';
|
||||
document.getElementById('album-header').appendChild(downloadAlbumBtn);
|
||||
}
|
||||
|
||||
if (isExplicitFilterEnabled && hasExplicitTrack) {
|
||||
// Disable the album download button and display a message explaining why
|
||||
downloadAlbumBtn.disabled = true;
|
||||
downloadAlbumBtn.classList.add('download-btn--disabled');
|
||||
downloadAlbumBtn.innerHTML = `<span title="Cannot download entire album because it contains explicit tracks">Album Contains Explicit Tracks</span>`;
|
||||
} else {
|
||||
// Normal behavior when no explicit tracks are present
|
||||
downloadAlbumBtn.addEventListener('click', () => {
|
||||
// Remove any other download buttons (keeping the full-album button in place).
|
||||
document.querySelectorAll('.download-btn').forEach(btn => {
|
||||
if (btn.id !== 'downloadAlbumBtn') btn.remove();
|
||||
});
|
||||
|
||||
downloadAlbumBtn.disabled = true;
|
||||
downloadAlbumBtn.textContent = 'Queueing...';
|
||||
|
||||
downloadWholeAlbum(album)
|
||||
.then(() => {
|
||||
downloadAlbumBtn.textContent = 'Queued!';
|
||||
})
|
||||
.catch(err => {
|
||||
showError('Failed to queue album download: ' + (err?.message || 'Unknown error'));
|
||||
downloadAlbumBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Render each track.
|
||||
const tracksList = document.getElementById('tracks-list');
|
||||
tracksList.innerHTML = '';
|
||||
|
||||
if (album.tracks?.items) {
|
||||
album.tracks.items.forEach((track, index) => {
|
||||
if (!track) return; // Skip null or undefined tracks
|
||||
|
||||
// Skip explicit tracks if filter is enabled
|
||||
if (isExplicitFilterEnabled && track.explicit) {
|
||||
// Add a placeholder for filtered explicit tracks
|
||||
const trackElement = document.createElement('div');
|
||||
trackElement.className = 'track track-filtered';
|
||||
trackElement.innerHTML = `
|
||||
<div class="track-number">${index + 1}</div>
|
||||
<div class="track-info">
|
||||
<div class="track-name explicit-filtered">Explicit Content Filtered</div>
|
||||
<div class="track-artist">This track is not shown due to explicit content filter settings</div>
|
||||
</div>
|
||||
<div class="track-duration">--:--</div>
|
||||
`;
|
||||
tracksList.appendChild(trackElement);
|
||||
return;
|
||||
}
|
||||
|
||||
const trackElement = document.createElement('div');
|
||||
trackElement.className = 'track';
|
||||
trackElement.innerHTML = `
|
||||
<div class="track-number">${index + 1}</div>
|
||||
<div class="track-info">
|
||||
<div class="track-name">
|
||||
<a href="${baseUrl}/track/${track.id || ''}">${track.name || 'Unknown Track'}</a>
|
||||
</div>
|
||||
<div class="track-artist">
|
||||
${track.artists?.map(a =>
|
||||
`<a href="${baseUrl}/artist/${a?.id || ''}">${a?.name || 'Unknown Artist'}</a>`
|
||||
).join(', ') || 'Unknown Artist'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-duration">${msToTime(track.duration_ms || 0)}</div>
|
||||
<button class="download-btn download-btn--circle"
|
||||
data-url="${track.external_urls?.spotify || ''}"
|
||||
data-type="track"
|
||||
data-name="${track.name || 'Unknown Track'}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
</button>
|
||||
`;
|
||||
tracksList.appendChild(trackElement);
|
||||
});
|
||||
}
|
||||
|
||||
// Reveal header and track list.
|
||||
document.getElementById('album-header').classList.remove('hidden');
|
||||
document.getElementById('tracks-container').classList.remove('hidden');
|
||||
attachDownloadListeners();
|
||||
|
||||
// If on a small screen, re-arrange the action buttons.
|
||||
if (window.innerWidth <= 480) {
|
||||
let actionsContainer = document.getElementById('album-actions');
|
||||
if (!actionsContainer) {
|
||||
actionsContainer = document.createElement('div');
|
||||
actionsContainer.id = 'album-actions';
|
||||
document.getElementById('album-header').appendChild(actionsContainer);
|
||||
}
|
||||
// Append in the desired order: Home, Download, then Queue Toggle (if exists).
|
||||
actionsContainer.innerHTML = ''; // Clear any previous content
|
||||
actionsContainer.appendChild(document.getElementById('homeButton'));
|
||||
actionsContainer.appendChild(document.getElementById('downloadAlbumBtn'));
|
||||
const queueToggle = document.querySelector('.queue-toggle');
|
||||
if (queueToggle) {
|
||||
actionsContainer.appendChild(queueToggle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadWholeAlbum(album) {
|
||||
const url = album.external_urls?.spotify || '';
|
||||
if (!url) {
|
||||
throw new Error('Missing album URL');
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the centralized downloadQueue.download method
|
||||
await downloadQueue.download(url, 'album', { name: album.name || 'Unknown Album' });
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error) {
|
||||
showError('Album download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function msToTime(duration) {
|
||||
const minutes = Math.floor(duration / 60000);
|
||||
const seconds = ((duration % 60000) / 1000).toFixed(0);
|
||||
return `${minutes}:${seconds.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const errorEl = document.getElementById('error');
|
||||
errorEl.textContent = message || 'An error occurred';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function attachDownloadListeners() {
|
||||
document.querySelectorAll('.download-btn').forEach((btn) => {
|
||||
if (btn.id === 'downloadAlbumBtn') return;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const url = e.currentTarget.dataset.url || '';
|
||||
const type = e.currentTarget.dataset.type || '';
|
||||
const name = e.currentTarget.dataset.name || extractName(url) || 'Unknown';
|
||||
// Remove the button immediately after click.
|
||||
e.currentTarget.remove();
|
||||
startDownload(url, type, { name });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startDownload(url, type, item, albumType) {
|
||||
if (!url) {
|
||||
showError('Missing URL for download');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the centralized downloadQueue.download method
|
||||
await downloadQueue.download(url, type, item, albumType);
|
||||
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error) {
|
||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function extractName(url) {
|
||||
return url || 'Unknown';
|
||||
}
|
||||
372
static/js/album.ts
Normal file
372
static/js/album.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import { downloadQueue } from './queue.js';
|
||||
|
||||
// Define interfaces for API data
|
||||
interface Image {
|
||||
url: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
interface Artist {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: {
|
||||
spotify: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Track {
|
||||
id: string;
|
||||
name: string;
|
||||
artists: Artist[];
|
||||
duration_ms: number;
|
||||
explicit: boolean;
|
||||
external_urls: {
|
||||
spotify: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
name: string;
|
||||
artists: Artist[];
|
||||
images: Image[];
|
||||
release_date: string;
|
||||
total_tracks: number;
|
||||
label: string;
|
||||
copyrights: { text: string; type: string }[];
|
||||
explicit: boolean;
|
||||
tracks: {
|
||||
items: Track[];
|
||||
// Add other properties from Spotify API if needed (e.g., total, limit, offset)
|
||||
};
|
||||
external_urls: {
|
||||
spotify: string;
|
||||
};
|
||||
// Add other album properties if available
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const pathSegments = window.location.pathname.split('/');
|
||||
const albumId = pathSegments[pathSegments.indexOf('album') + 1];
|
||||
|
||||
if (!albumId) {
|
||||
showError('No album ID provided.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch album info directly
|
||||
fetch(`/api/album/info?id=${encodeURIComponent(albumId)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return response.json() as Promise<Album>; // Add Album type
|
||||
})
|
||||
.then(data => renderAlbum(data))
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showError('Failed to load album.');
|
||||
});
|
||||
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => {
|
||||
downloadQueue.toggleVisibility();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function renderAlbum(album: Album) {
|
||||
// Hide loading and error messages.
|
||||
const loadingEl = document.getElementById('loading');
|
||||
if (loadingEl) loadingEl.classList.add('hidden');
|
||||
|
||||
const errorSectionEl = document.getElementById('error'); // Renamed to avoid conflict with error var in catch
|
||||
if (errorSectionEl) errorSectionEl.classList.add('hidden');
|
||||
|
||||
// Check if album itself is marked explicit and filter is enabled
|
||||
const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled();
|
||||
if (isExplicitFilterEnabled && album.explicit) {
|
||||
// Show placeholder for explicit album
|
||||
const placeholderContent = `
|
||||
<div class="explicit-filter-placeholder">
|
||||
<h2>Explicit Content Filtered</h2>
|
||||
<p>This album contains explicit content and has been filtered based on your settings.</p>
|
||||
<p>The explicit content filter is controlled by environment variables.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const contentContainer = document.getElementById('album-header');
|
||||
if (contentContainer) {
|
||||
contentContainer.innerHTML = placeholderContent;
|
||||
contentContainer.classList.remove('hidden');
|
||||
}
|
||||
|
||||
return; // Stop rendering the actual album content
|
||||
}
|
||||
|
||||
const baseUrl = window.location.origin;
|
||||
|
||||
// Set album header info.
|
||||
const albumNameEl = document.getElementById('album-name');
|
||||
if (albumNameEl) {
|
||||
albumNameEl.innerHTML =
|
||||
`<a href="${baseUrl}/album/${album.id || ''}">${album.name || 'Unknown Album'}</a>`;
|
||||
}
|
||||
|
||||
const albumArtistEl = document.getElementById('album-artist');
|
||||
if (albumArtistEl) {
|
||||
albumArtistEl.innerHTML =
|
||||
`By ${album.artists?.map(artist =>
|
||||
`<a href="${baseUrl}/artist/${artist?.id || ''}">${artist?.name || 'Unknown Artist'}</a>`
|
||||
).join(', ') || 'Unknown Artist'}`;
|
||||
}
|
||||
|
||||
const releaseYear = album.release_date ? new Date(album.release_date).getFullYear() : 'N/A';
|
||||
const albumStatsEl = document.getElementById('album-stats');
|
||||
if (albumStatsEl) {
|
||||
albumStatsEl.textContent =
|
||||
`${releaseYear} • ${album.total_tracks || '0'} songs • ${album.label || 'Unknown Label'}`;
|
||||
}
|
||||
|
||||
const albumCopyrightEl = document.getElementById('album-copyright');
|
||||
if (albumCopyrightEl) {
|
||||
albumCopyrightEl.textContent =
|
||||
album.copyrights?.map(c => c?.text || '').filter(text => text).join(' • ') || '';
|
||||
}
|
||||
|
||||
const imageSrc = album.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||
const albumImageEl = document.getElementById('album-image') as HTMLImageElement | null;
|
||||
if (albumImageEl) {
|
||||
albumImageEl.src = imageSrc;
|
||||
}
|
||||
|
||||
// Create (if needed) the Home Button.
|
||||
let homeButton = document.getElementById('homeButton') as HTMLButtonElement | null;
|
||||
if (!homeButton) {
|
||||
homeButton = document.createElement('button');
|
||||
homeButton.id = 'homeButton';
|
||||
homeButton.className = 'home-btn';
|
||||
|
||||
const homeIcon = document.createElement('img');
|
||||
homeIcon.src = '/static/images/home.svg';
|
||||
homeIcon.alt = 'Home';
|
||||
homeButton.appendChild(homeIcon);
|
||||
|
||||
// Insert as first child of album-header.
|
||||
const headerContainer = document.getElementById('album-header');
|
||||
if (headerContainer) { // Null check
|
||||
headerContainer.insertBefore(homeButton, headerContainer.firstChild);
|
||||
}
|
||||
}
|
||||
if (homeButton) { // Null check
|
||||
homeButton.addEventListener('click', () => {
|
||||
window.location.href = window.location.origin;
|
||||
});
|
||||
}
|
||||
|
||||
// Check if any track in the album is explicit when filter is enabled
|
||||
let hasExplicitTrack = false;
|
||||
if (isExplicitFilterEnabled && album.tracks?.items) {
|
||||
hasExplicitTrack = album.tracks.items.some(track => track && track.explicit);
|
||||
}
|
||||
|
||||
// Create (if needed) the Download Album Button.
|
||||
let downloadAlbumBtn = document.getElementById('downloadAlbumBtn') as HTMLButtonElement | null;
|
||||
if (!downloadAlbumBtn) {
|
||||
downloadAlbumBtn = document.createElement('button');
|
||||
downloadAlbumBtn.id = 'downloadAlbumBtn';
|
||||
downloadAlbumBtn.textContent = 'Download Full Album';
|
||||
downloadAlbumBtn.className = 'download-btn download-btn--main';
|
||||
const albumHeader = document.getElementById('album-header');
|
||||
if (albumHeader) albumHeader.appendChild(downloadAlbumBtn); // Null check
|
||||
}
|
||||
|
||||
if (downloadAlbumBtn) { // Null check for downloadAlbumBtn
|
||||
if (isExplicitFilterEnabled && hasExplicitTrack) {
|
||||
// Disable the album download button and display a message explaining why
|
||||
downloadAlbumBtn.disabled = true;
|
||||
downloadAlbumBtn.classList.add('download-btn--disabled');
|
||||
downloadAlbumBtn.innerHTML = `<span title="Cannot download entire album because it contains explicit tracks">Album Contains Explicit Tracks</span>`;
|
||||
} else {
|
||||
// Normal behavior when no explicit tracks are present
|
||||
downloadAlbumBtn.addEventListener('click', () => {
|
||||
// Remove any other download buttons (keeping the full-album button in place).
|
||||
document.querySelectorAll('.download-btn').forEach(btn => {
|
||||
if (btn.id !== 'downloadAlbumBtn') btn.remove();
|
||||
});
|
||||
|
||||
if (downloadAlbumBtn) { // Inner null check
|
||||
downloadAlbumBtn.disabled = true;
|
||||
downloadAlbumBtn.textContent = 'Queueing...';
|
||||
}
|
||||
|
||||
downloadWholeAlbum(album)
|
||||
.then(() => {
|
||||
if (downloadAlbumBtn) downloadAlbumBtn.textContent = 'Queued!'; // Inner null check
|
||||
})
|
||||
.catch(err => {
|
||||
showError('Failed to queue album download: ' + (err?.message || 'Unknown error'));
|
||||
if (downloadAlbumBtn) downloadAlbumBtn.disabled = false; // Inner null check
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Render each track.
|
||||
const tracksList = document.getElementById('tracks-list');
|
||||
if (tracksList) { // Null check
|
||||
tracksList.innerHTML = '';
|
||||
|
||||
if (album.tracks?.items) {
|
||||
album.tracks.items.forEach((track, index) => {
|
||||
if (!track) return; // Skip null or undefined tracks
|
||||
|
||||
// Skip explicit tracks if filter is enabled
|
||||
if (isExplicitFilterEnabled && track.explicit) {
|
||||
// Add a placeholder for filtered explicit tracks
|
||||
const trackElement = document.createElement('div');
|
||||
trackElement.className = 'track track-filtered';
|
||||
trackElement.innerHTML = `
|
||||
<div class="track-number">${index + 1}</div>
|
||||
<div class="track-info">
|
||||
<div class="track-name explicit-filtered">Explicit Content Filtered</div>
|
||||
<div class="track-artist">This track is not shown due to explicit content filter settings</div>
|
||||
</div>
|
||||
<div class="track-duration">--:--</div>
|
||||
`;
|
||||
tracksList.appendChild(trackElement);
|
||||
return;
|
||||
}
|
||||
|
||||
const trackElement = document.createElement('div');
|
||||
trackElement.className = 'track';
|
||||
trackElement.innerHTML = `
|
||||
<div class="track-number">${index + 1}</div>
|
||||
<div class="track-info">
|
||||
<div class="track-name">
|
||||
<a href="${baseUrl}/track/${track.id || ''}">${track.name || 'Unknown Track'}</a>
|
||||
</div>
|
||||
<div class="track-artist">
|
||||
${track.artists?.map(a =>
|
||||
`<a href="${baseUrl}/artist/${a?.id || ''}">${a?.name || 'Unknown Artist'}</a>`
|
||||
).join(', ') || 'Unknown Artist'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-duration">${msToTime(track.duration_ms || 0)}</div>
|
||||
<button class="download-btn download-btn--circle"
|
||||
data-url="${track.external_urls?.spotify || ''}"
|
||||
data-type="track"
|
||||
data-name="${track.name || 'Unknown Track'}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
</button>
|
||||
`;
|
||||
tracksList.appendChild(trackElement);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reveal header and track list.
|
||||
const albumHeaderEl = document.getElementById('album-header');
|
||||
if (albumHeaderEl) albumHeaderEl.classList.remove('hidden');
|
||||
|
||||
const tracksContainerEl = document.getElementById('tracks-container');
|
||||
if (tracksContainerEl) tracksContainerEl.classList.remove('hidden');
|
||||
attachDownloadListeners();
|
||||
|
||||
// If on a small screen, re-arrange the action buttons.
|
||||
if (window.innerWidth <= 480) {
|
||||
let actionsContainer = document.getElementById('album-actions');
|
||||
if (!actionsContainer) {
|
||||
actionsContainer = document.createElement('div');
|
||||
actionsContainer.id = 'album-actions';
|
||||
const albumHeader = document.getElementById('album-header');
|
||||
if (albumHeader) albumHeader.appendChild(actionsContainer); // Null check
|
||||
}
|
||||
if (actionsContainer) { // Null check for actionsContainer
|
||||
actionsContainer.innerHTML = ''; // Clear any previous content
|
||||
const homeBtn = document.getElementById('homeButton');
|
||||
if (homeBtn) actionsContainer.appendChild(homeBtn); // Null check
|
||||
|
||||
const dlAlbumBtn = document.getElementById('downloadAlbumBtn');
|
||||
if (dlAlbumBtn) actionsContainer.appendChild(dlAlbumBtn); // Null check
|
||||
|
||||
const queueToggle = document.querySelector('.queue-toggle');
|
||||
if (queueToggle) {
|
||||
actionsContainer.appendChild(queueToggle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadWholeAlbum(album: Album) {
|
||||
const url = album.external_urls?.spotify || '';
|
||||
if (!url) {
|
||||
throw new Error('Missing album URL');
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the centralized downloadQueue.download method
|
||||
await downloadQueue.download(url, 'album', { name: album.name || 'Unknown Album' });
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error: any) { // Add type for error
|
||||
showError('Album download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function msToTime(duration: number): string {
|
||||
const minutes = Math.floor(duration / 60000);
|
||||
const seconds = ((duration % 60000) / 1000).toFixed(0);
|
||||
return `${minutes}:${seconds.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function showError(message: string) {
|
||||
const errorEl = document.getElementById('error');
|
||||
if (errorEl) { // Null check
|
||||
errorEl.textContent = message || 'An error occurred';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function attachDownloadListeners() {
|
||||
document.querySelectorAll('.download-btn').forEach((btn) => {
|
||||
const button = btn as HTMLButtonElement; // Cast to HTMLButtonElement
|
||||
if (button.id === 'downloadAlbumBtn') return;
|
||||
button.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const currentTarget = e.currentTarget as HTMLButtonElement | null; // Cast currentTarget
|
||||
if (!currentTarget) return;
|
||||
|
||||
const url = currentTarget.dataset.url || '';
|
||||
const type = currentTarget.dataset.type || '';
|
||||
const name = currentTarget.dataset.name || extractName(url) || 'Unknown';
|
||||
// Remove the button immediately after click.
|
||||
currentTarget.remove();
|
||||
startDownload(url, type, { name }); // albumType will be undefined
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startDownload(url: string, type: string, item: { name: string }, albumType?: string) { // Add types and make albumType optional
|
||||
if (!url) {
|
||||
showError('Missing URL for download');
|
||||
return Promise.reject(new Error('Missing URL for download')); // Return a rejected promise
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the centralized downloadQueue.download method
|
||||
await downloadQueue.download(url, type, item, albumType);
|
||||
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error: any) { // Add type for error
|
||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function extractName(url: string | null | undefined): string { // Add type
|
||||
return url || 'Unknown';
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
// Import the downloadQueue singleton
|
||||
import { downloadQueue } from './queue.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const pathSegments = window.location.pathname.split('/');
|
||||
const artistId = pathSegments[pathSegments.indexOf('artist') + 1];
|
||||
|
||||
if (!artistId) {
|
||||
showError('No artist ID provided.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch artist info directly
|
||||
fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => renderArtist(data, artistId))
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showError('Failed to load artist info.');
|
||||
});
|
||||
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility());
|
||||
}
|
||||
});
|
||||
|
||||
function renderArtist(artistData, artistId) {
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
document.getElementById('error').classList.add('hidden');
|
||||
|
||||
// Check if explicit filter is enabled
|
||||
const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled();
|
||||
|
||||
const firstAlbum = artistData.items?.[0] || {};
|
||||
const artistName = firstAlbum?.artists?.[0]?.name || 'Unknown Artist';
|
||||
const artistImage = firstAlbum?.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||
|
||||
document.getElementById('artist-name').innerHTML =
|
||||
`<a href="/artist/${artistId}" class="artist-link">${artistName}</a>`;
|
||||
document.getElementById('artist-stats').textContent = `${artistData.total || '0'} albums`;
|
||||
document.getElementById('artist-image').src = artistImage;
|
||||
|
||||
// Define the artist URL (used by both full-discography and group downloads)
|
||||
const artistUrl = `https://open.spotify.com/artist/${artistId}`;
|
||||
|
||||
// Home Button
|
||||
let homeButton = document.getElementById('homeButton');
|
||||
if (!homeButton) {
|
||||
homeButton = document.createElement('button');
|
||||
homeButton.id = 'homeButton';
|
||||
homeButton.className = 'home-btn';
|
||||
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home">`;
|
||||
document.getElementById('artist-header').prepend(homeButton);
|
||||
}
|
||||
homeButton.addEventListener('click', () => window.location.href = window.location.origin);
|
||||
|
||||
// Download Whole Artist Button using the new artist API endpoint
|
||||
let downloadArtistBtn = document.getElementById('downloadArtistBtn');
|
||||
if (!downloadArtistBtn) {
|
||||
downloadArtistBtn = document.createElement('button');
|
||||
downloadArtistBtn.id = 'downloadArtistBtn';
|
||||
downloadArtistBtn.className = 'download-btn download-btn--main';
|
||||
downloadArtistBtn.textContent = 'Download All Discography';
|
||||
document.getElementById('artist-header').appendChild(downloadArtistBtn);
|
||||
}
|
||||
|
||||
// When explicit filter is enabled, disable all download buttons
|
||||
if (isExplicitFilterEnabled) {
|
||||
// Disable the artist download button and display a message explaining why
|
||||
downloadArtistBtn.disabled = true;
|
||||
downloadArtistBtn.classList.add('download-btn--disabled');
|
||||
downloadArtistBtn.innerHTML = `<span title="Direct artist downloads are restricted when explicit filter is enabled. Please visit individual album pages.">Downloads Restricted</span>`;
|
||||
} else {
|
||||
// Normal behavior when explicit filter is not enabled
|
||||
downloadArtistBtn.addEventListener('click', () => {
|
||||
// Optionally remove other download buttons from individual albums.
|
||||
document.querySelectorAll('.download-btn:not(#downloadArtistBtn)').forEach(btn => btn.remove());
|
||||
downloadArtistBtn.disabled = true;
|
||||
downloadArtistBtn.textContent = 'Queueing...';
|
||||
|
||||
// Queue the entire discography (albums, singles, compilations, and appears_on)
|
||||
// Use our local startDownload function instead of downloadQueue.startArtistDownload
|
||||
startDownload(
|
||||
artistUrl,
|
||||
'artist',
|
||||
{ name: artistName, artist: artistName },
|
||||
'album,single,compilation,appears_on'
|
||||
)
|
||||
.then((taskIds) => {
|
||||
downloadArtistBtn.textContent = 'Artist queued';
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
|
||||
// Optionally show number of albums queued
|
||||
if (Array.isArray(taskIds)) {
|
||||
downloadArtistBtn.title = `${taskIds.length} albums queued for download`;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
downloadArtistBtn.textContent = 'Download All Discography';
|
||||
downloadArtistBtn.disabled = false;
|
||||
showError('Failed to queue artist download: ' + (err?.message || 'Unknown error'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Group albums by type (album, single, compilation, etc.) and separate "appears_on" albums
|
||||
const albumGroups = {};
|
||||
const appearingAlbums = [];
|
||||
|
||||
(artistData.items || []).forEach(album => {
|
||||
if (!album) return;
|
||||
|
||||
// Skip explicit albums if filter is enabled
|
||||
if (isExplicitFilterEnabled && album.explicit) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an "appears_on" album
|
||||
if (album.album_group === 'appears_on') {
|
||||
appearingAlbums.push(album);
|
||||
} else {
|
||||
// Group by album_type for the artist's own releases
|
||||
const type = (album.album_type || 'unknown').toLowerCase();
|
||||
if (!albumGroups[type]) albumGroups[type] = [];
|
||||
albumGroups[type].push(album);
|
||||
}
|
||||
});
|
||||
|
||||
// Render album groups
|
||||
const groupsContainer = document.getElementById('album-groups');
|
||||
groupsContainer.innerHTML = '';
|
||||
|
||||
// Render regular album groups first
|
||||
for (const [groupType, albums] of Object.entries(albumGroups)) {
|
||||
const groupSection = document.createElement('section');
|
||||
groupSection.className = 'album-group';
|
||||
|
||||
// If explicit filter is enabled, don't show the group download button
|
||||
const groupHeaderHTML = isExplicitFilterEnabled ?
|
||||
`<div class="album-group-header">
|
||||
<h3>${capitalize(groupType)}s</h3>
|
||||
<div class="download-note">Visit album pages to download content</div>
|
||||
</div>` :
|
||||
`<div class="album-group-header">
|
||||
<h3>${capitalize(groupType)}s</h3>
|
||||
<button class="download-btn download-btn--main group-download-btn"
|
||||
data-group-type="${groupType}">
|
||||
Download All ${capitalize(groupType)}s
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
groupSection.innerHTML = `
|
||||
${groupHeaderHTML}
|
||||
<div class="albums-list"></div>
|
||||
`;
|
||||
|
||||
const albumsContainer = groupSection.querySelector('.albums-list');
|
||||
albums.forEach(album => {
|
||||
if (!album) return;
|
||||
|
||||
const albumElement = document.createElement('div');
|
||||
albumElement.className = 'album-card';
|
||||
|
||||
// Create album card with or without download button based on explicit filter setting
|
||||
if (isExplicitFilterEnabled) {
|
||||
albumElement.innerHTML = `
|
||||
<a href="/album/${album.id || ''}" class="album-link">
|
||||
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
|
||||
alt="Album cover"
|
||||
class="album-cover">
|
||||
</a>
|
||||
<div class="album-info">
|
||||
<div class="album-title">${album.name || 'Unknown Album'}</div>
|
||||
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
albumElement.innerHTML = `
|
||||
<a href="/album/${album.id || ''}" class="album-link">
|
||||
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
|
||||
alt="Album cover"
|
||||
class="album-cover">
|
||||
</a>
|
||||
<div class="album-info">
|
||||
<div class="album-title">${album.name || 'Unknown Album'}</div>
|
||||
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
|
||||
</div>
|
||||
<button class="download-btn download-btn--circle"
|
||||
data-url="${album.external_urls?.spotify || ''}"
|
||||
data-type="${album.album_type || 'album'}"
|
||||
data-name="${album.name || 'Unknown Album'}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
albumsContainer.appendChild(albumElement);
|
||||
});
|
||||
|
||||
groupsContainer.appendChild(groupSection);
|
||||
}
|
||||
|
||||
// Render "Featuring" section if there are any appearing albums
|
||||
if (appearingAlbums.length > 0) {
|
||||
const featuringSection = document.createElement('section');
|
||||
featuringSection.className = 'album-group';
|
||||
|
||||
const featuringHeaderHTML = isExplicitFilterEnabled ?
|
||||
`<div class="album-group-header">
|
||||
<h3>Featuring</h3>
|
||||
<div class="download-note">Visit album pages to download content</div>
|
||||
</div>` :
|
||||
`<div class="album-group-header">
|
||||
<h3>Featuring</h3>
|
||||
<button class="download-btn download-btn--main group-download-btn"
|
||||
data-group-type="appears_on">
|
||||
Download All Featuring Albums
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
featuringSection.innerHTML = `
|
||||
${featuringHeaderHTML}
|
||||
<div class="albums-list"></div>
|
||||
`;
|
||||
|
||||
const albumsContainer = featuringSection.querySelector('.albums-list');
|
||||
appearingAlbums.forEach(album => {
|
||||
if (!album) return;
|
||||
|
||||
const albumElement = document.createElement('div');
|
||||
albumElement.className = 'album-card';
|
||||
|
||||
// Create album card with or without download button based on explicit filter setting
|
||||
if (isExplicitFilterEnabled) {
|
||||
albumElement.innerHTML = `
|
||||
<a href="/album/${album.id || ''}" class="album-link">
|
||||
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
|
||||
alt="Album cover"
|
||||
class="album-cover">
|
||||
</a>
|
||||
<div class="album-info">
|
||||
<div class="album-title">${album.name || 'Unknown Album'}</div>
|
||||
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
albumElement.innerHTML = `
|
||||
<a href="/album/${album.id || ''}" class="album-link">
|
||||
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
|
||||
alt="Album cover"
|
||||
class="album-cover">
|
||||
</a>
|
||||
<div class="album-info">
|
||||
<div class="album-title">${album.name || 'Unknown Album'}</div>
|
||||
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
|
||||
</div>
|
||||
<button class="download-btn download-btn--circle"
|
||||
data-url="${album.external_urls?.spotify || ''}"
|
||||
data-type="${album.album_type || 'album'}"
|
||||
data-name="${album.name || 'Unknown Album'}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
albumsContainer.appendChild(albumElement);
|
||||
});
|
||||
|
||||
// Add to the end so it appears at the bottom
|
||||
groupsContainer.appendChild(featuringSection);
|
||||
}
|
||||
|
||||
document.getElementById('artist-header').classList.remove('hidden');
|
||||
document.getElementById('albums-container').classList.remove('hidden');
|
||||
|
||||
// Only attach download listeners if explicit filter is not enabled
|
||||
if (!isExplicitFilterEnabled) {
|
||||
attachDownloadListeners();
|
||||
// Pass the artist URL and name so the group buttons can use the artist download function
|
||||
attachGroupDownloadListeners(artistUrl, artistName);
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners for group downloads using the artist download function
|
||||
function attachGroupDownloadListeners(artistUrl, artistName) {
|
||||
document.querySelectorAll('.group-download-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const groupType = e.target.dataset.groupType || 'album'; // e.g. "album", "single", "compilation", "appears_on"
|
||||
e.target.disabled = true;
|
||||
|
||||
// Custom text for the 'appears_on' group
|
||||
const displayType = groupType === 'appears_on' ? 'Featuring Albums' : `${capitalize(groupType)}s`;
|
||||
e.target.textContent = `Queueing all ${displayType}...`;
|
||||
|
||||
try {
|
||||
// Use our local startDownload function with the group type filter
|
||||
const taskIds = await startDownload(
|
||||
artistUrl,
|
||||
'artist',
|
||||
{ name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' },
|
||||
groupType // Only queue releases of this specific type.
|
||||
);
|
||||
|
||||
// Optionally show number of albums queued
|
||||
const totalQueued = Array.isArray(taskIds) ? taskIds.length : 0;
|
||||
e.target.textContent = `Queued all ${displayType}`;
|
||||
e.target.title = `${totalQueued} albums queued for download`;
|
||||
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error) {
|
||||
e.target.textContent = `Download All ${displayType}`;
|
||||
e.target.disabled = false;
|
||||
showError(`Failed to queue download for all ${groupType}: ${error?.message || 'Unknown error'}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Individual download handlers remain unchanged.
|
||||
function attachDownloadListeners() {
|
||||
document.querySelectorAll('.download-btn:not(.group-download-btn)').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const url = e.currentTarget.dataset.url || '';
|
||||
const name = e.currentTarget.dataset.name || 'Unknown';
|
||||
// Always use 'album' type for individual album downloads regardless of category
|
||||
const type = 'album';
|
||||
|
||||
e.currentTarget.remove();
|
||||
// Use the centralized downloadQueue.download method
|
||||
downloadQueue.download(url, type, { name, type })
|
||||
.catch(err => showError('Download failed: ' + (err?.message || 'Unknown error')));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add startDownload function (similar to track.js and main.js)
|
||||
/**
|
||||
* Starts the download process via centralized download queue
|
||||
*/
|
||||
async function startDownload(url, type, item, albumType) {
|
||||
if (!url || !type) {
|
||||
showError('Missing URL or type for download');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the centralized downloadQueue.download method for all downloads including artist downloads
|
||||
const result = await downloadQueue.download(url, type, item, albumType);
|
||||
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
|
||||
// Return the result for tracking
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// UI Helpers
|
||||
function showError(message) {
|
||||
const errorEl = document.getElementById('error');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message || 'An error occurred';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function capitalize(str) {
|
||||
return str ? str.charAt(0).toUpperCase() + str.slice(1) : '';
|
||||
}
|
||||
460
static/js/artist.ts
Normal file
460
static/js/artist.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
// Import the downloadQueue singleton
|
||||
import { downloadQueue } from './queue.js';
|
||||
|
||||
// Define interfaces for API data
|
||||
interface Image {
|
||||
url: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
interface Artist {
|
||||
id: string;
|
||||
name: string;
|
||||
external_urls: {
|
||||
spotify: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Album {
|
||||
id: string;
|
||||
name: string;
|
||||
artists: Artist[];
|
||||
images: Image[];
|
||||
album_type: string; // "album", "single", "compilation"
|
||||
album_group?: string; // "album", "single", "compilation", "appears_on"
|
||||
external_urls: {
|
||||
spotify: string;
|
||||
};
|
||||
explicit?: boolean; // Added to handle explicit filter
|
||||
total_tracks?: number;
|
||||
release_date?: string;
|
||||
}
|
||||
|
||||
interface ArtistData {
|
||||
items: Album[];
|
||||
total: number;
|
||||
// Add other properties if available from the API
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const pathSegments = window.location.pathname.split('/');
|
||||
const artistId = pathSegments[pathSegments.indexOf('artist') + 1];
|
||||
|
||||
if (!artistId) {
|
||||
showError('No artist ID provided.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch artist info directly
|
||||
fetch(`/api/artist/info?id=${encodeURIComponent(artistId)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return response.json() as Promise<ArtistData>;
|
||||
})
|
||||
.then(data => renderArtist(data, artistId))
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showError('Failed to load artist info.');
|
||||
});
|
||||
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => downloadQueue.toggleVisibility());
|
||||
}
|
||||
});
|
||||
|
||||
function renderArtist(artistData: ArtistData, artistId: string) {
|
||||
const loadingEl = document.getElementById('loading');
|
||||
if (loadingEl) loadingEl.classList.add('hidden');
|
||||
|
||||
const errorEl = document.getElementById('error');
|
||||
if (errorEl) errorEl.classList.add('hidden');
|
||||
|
||||
// Check if explicit filter is enabled
|
||||
const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled();
|
||||
|
||||
const firstAlbum = artistData.items?.[0];
|
||||
const artistName = firstAlbum?.artists?.[0]?.name || 'Unknown Artist';
|
||||
const artistImageSrc = firstAlbum?.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||
|
||||
const artistNameEl = document.getElementById('artist-name');
|
||||
if (artistNameEl) {
|
||||
artistNameEl.innerHTML =
|
||||
`<a href="/artist/${artistId}" class="artist-link">${artistName}</a>`;
|
||||
}
|
||||
const artistStatsEl = document.getElementById('artist-stats');
|
||||
if (artistStatsEl) {
|
||||
artistStatsEl.textContent = `${artistData.total || '0'} albums`;
|
||||
}
|
||||
const artistImageEl = document.getElementById('artist-image') as HTMLImageElement | null;
|
||||
if (artistImageEl) {
|
||||
artistImageEl.src = artistImageSrc;
|
||||
}
|
||||
|
||||
// Define the artist URL (used by both full-discography and group downloads)
|
||||
const artistUrl = `https://open.spotify.com/artist/${artistId}`;
|
||||
|
||||
// Home Button
|
||||
let homeButton = document.getElementById('homeButton') as HTMLButtonElement | null;
|
||||
if (!homeButton) {
|
||||
homeButton = document.createElement('button');
|
||||
homeButton.id = 'homeButton';
|
||||
homeButton.className = 'home-btn';
|
||||
homeButton.innerHTML = `<img src="/static/images/home.svg" alt="Home">`;
|
||||
const artistHeader = document.getElementById('artist-header');
|
||||
if (artistHeader) artistHeader.prepend(homeButton);
|
||||
}
|
||||
if (homeButton) {
|
||||
homeButton.addEventListener('click', () => window.location.href = window.location.origin);
|
||||
}
|
||||
|
||||
// Download Whole Artist Button using the new artist API endpoint
|
||||
let downloadArtistBtn = document.getElementById('downloadArtistBtn') as HTMLButtonElement | null;
|
||||
if (!downloadArtistBtn) {
|
||||
downloadArtistBtn = document.createElement('button');
|
||||
downloadArtistBtn.id = 'downloadArtistBtn';
|
||||
downloadArtistBtn.className = 'download-btn download-btn--main';
|
||||
downloadArtistBtn.textContent = 'Download All Discography';
|
||||
const artistHeader = document.getElementById('artist-header');
|
||||
if (artistHeader) artistHeader.appendChild(downloadArtistBtn);
|
||||
}
|
||||
|
||||
// When explicit filter is enabled, disable all download buttons
|
||||
if (isExplicitFilterEnabled) {
|
||||
if (downloadArtistBtn) {
|
||||
// Disable the artist download button and display a message explaining why
|
||||
downloadArtistBtn.disabled = true;
|
||||
downloadArtistBtn.classList.add('download-btn--disabled');
|
||||
downloadArtistBtn.innerHTML = `<span title="Direct artist downloads are restricted when explicit filter is enabled. Please visit individual album pages.">Downloads Restricted</span>`;
|
||||
}
|
||||
} else {
|
||||
// Normal behavior when explicit filter is not enabled
|
||||
if (downloadArtistBtn) {
|
||||
downloadArtistBtn.addEventListener('click', () => {
|
||||
// Optionally remove other download buttons from individual albums.
|
||||
document.querySelectorAll('.download-btn:not(#downloadArtistBtn)').forEach(btn => btn.remove());
|
||||
if (downloadArtistBtn) {
|
||||
downloadArtistBtn.disabled = true;
|
||||
downloadArtistBtn.textContent = 'Queueing...';
|
||||
}
|
||||
|
||||
// Queue the entire discography (albums, singles, compilations, and appears_on)
|
||||
// Use our local startDownload function instead of downloadQueue.startArtistDownload
|
||||
startDownload(
|
||||
artistUrl,
|
||||
'artist',
|
||||
{ name: artistName, artist: artistName },
|
||||
'album,single,compilation,appears_on'
|
||||
)
|
||||
.then((taskIds) => {
|
||||
if (downloadArtistBtn) {
|
||||
downloadArtistBtn.textContent = 'Artist queued';
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
|
||||
// Optionally show number of albums queued
|
||||
if (Array.isArray(taskIds)) {
|
||||
downloadArtistBtn.title = `${taskIds.length} albums queued for download`;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
if (downloadArtistBtn) {
|
||||
downloadArtistBtn.textContent = 'Download All Discography';
|
||||
downloadArtistBtn.disabled = false;
|
||||
}
|
||||
showError('Failed to queue artist download: ' + (err?.message || 'Unknown error'));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Group albums by type (album, single, compilation, etc.) and separate "appears_on" albums
|
||||
const albumGroups: Record<string, Album[]> = {};
|
||||
const appearingAlbums: Album[] = [];
|
||||
|
||||
(artistData.items || []).forEach(album => {
|
||||
if (!album) return;
|
||||
|
||||
// Skip explicit albums if filter is enabled
|
||||
if (isExplicitFilterEnabled && album.explicit) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is an "appears_on" album
|
||||
if (album.album_group === 'appears_on') {
|
||||
appearingAlbums.push(album);
|
||||
} else {
|
||||
// Group by album_type for the artist's own releases
|
||||
const type = (album.album_type || 'unknown').toLowerCase();
|
||||
if (!albumGroups[type]) albumGroups[type] = [];
|
||||
albumGroups[type].push(album);
|
||||
}
|
||||
});
|
||||
|
||||
// Render album groups
|
||||
const groupsContainer = document.getElementById('album-groups');
|
||||
if (groupsContainer) {
|
||||
groupsContainer.innerHTML = '';
|
||||
|
||||
// Render regular album groups first
|
||||
for (const [groupType, albums] of Object.entries(albumGroups)) {
|
||||
const groupSection = document.createElement('section');
|
||||
groupSection.className = 'album-group';
|
||||
|
||||
// If explicit filter is enabled, don't show the group download button
|
||||
const groupHeaderHTML = isExplicitFilterEnabled ?
|
||||
`<div class="album-group-header">
|
||||
<h3>${capitalize(groupType)}s</h3>
|
||||
<div class="download-note">Visit album pages to download content</div>
|
||||
</div>` :
|
||||
`<div class="album-group-header">
|
||||
<h3>${capitalize(groupType)}s</h3>
|
||||
<button class="download-btn download-btn--main group-download-btn"
|
||||
data-group-type="${groupType}">
|
||||
Download All ${capitalize(groupType)}s
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
groupSection.innerHTML = `
|
||||
${groupHeaderHTML}
|
||||
<div class="albums-list"></div>
|
||||
`;
|
||||
|
||||
const albumsContainer = groupSection.querySelector('.albums-list');
|
||||
if (albumsContainer) {
|
||||
albums.forEach(album => {
|
||||
if (!album) return;
|
||||
|
||||
const albumElement = document.createElement('div');
|
||||
albumElement.className = 'album-card';
|
||||
|
||||
// Create album card with or without download button based on explicit filter setting
|
||||
if (isExplicitFilterEnabled) {
|
||||
albumElement.innerHTML = `
|
||||
<a href="/album/${album.id || ''}" class="album-link">
|
||||
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
|
||||
alt="Album cover"
|
||||
class="album-cover">
|
||||
</a>
|
||||
<div class="album-info">
|
||||
<div class="album-title">${album.name || 'Unknown Album'}</div>
|
||||
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
albumElement.innerHTML = `
|
||||
<a href="/album/${album.id || ''}" class="album-link">
|
||||
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
|
||||
alt="Album cover"
|
||||
class="album-cover">
|
||||
</a>
|
||||
<div class="album-info">
|
||||
<div class="album-title">${album.name || 'Unknown Album'}</div>
|
||||
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
|
||||
</div>
|
||||
<button class="download-btn download-btn--circle"
|
||||
data-url="${album.external_urls?.spotify || ''}"
|
||||
data-type="${album.album_type || 'album'}"
|
||||
data-name="${album.name || 'Unknown Album'}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
albumsContainer.appendChild(albumElement);
|
||||
});
|
||||
}
|
||||
|
||||
groupsContainer.appendChild(groupSection);
|
||||
}
|
||||
|
||||
// Render "Featuring" section if there are any appearing albums
|
||||
if (appearingAlbums.length > 0) {
|
||||
const featuringSection = document.createElement('section');
|
||||
featuringSection.className = 'album-group';
|
||||
|
||||
const featuringHeaderHTML = isExplicitFilterEnabled ?
|
||||
`<div class="album-group-header">
|
||||
<h3>Featuring</h3>
|
||||
<div class="download-note">Visit album pages to download content</div>
|
||||
</div>` :
|
||||
`<div class="album-group-header">
|
||||
<h3>Featuring</h3>
|
||||
<button class="download-btn download-btn--main group-download-btn"
|
||||
data-group-type="appears_on">
|
||||
Download All Featuring Albums
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
featuringSection.innerHTML = `
|
||||
${featuringHeaderHTML}
|
||||
<div class="albums-list"></div>
|
||||
`;
|
||||
|
||||
const albumsContainer = featuringSection.querySelector('.albums-list');
|
||||
if (albumsContainer) {
|
||||
appearingAlbums.forEach(album => {
|
||||
if (!album) return;
|
||||
|
||||
const albumElement = document.createElement('div');
|
||||
albumElement.className = 'album-card';
|
||||
|
||||
// Create album card with or without download button based on explicit filter setting
|
||||
if (isExplicitFilterEnabled) {
|
||||
albumElement.innerHTML = `
|
||||
<a href="/album/${album.id || ''}" class="album-link">
|
||||
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
|
||||
alt="Album cover"
|
||||
class="album-cover">
|
||||
</a>
|
||||
<div class="album-info">
|
||||
<div class="album-title">${album.name || 'Unknown Album'}</div>
|
||||
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
albumElement.innerHTML = `
|
||||
<a href="/album/${album.id || ''}" class="album-link">
|
||||
<img src="${album.images?.[1]?.url || album.images?.[0]?.url || '/static/images/placeholder.jpg'}"
|
||||
alt="Album cover"
|
||||
class="album-cover">
|
||||
</a>
|
||||
<div class="album-info">
|
||||
<div class="album-title">${album.name || 'Unknown Album'}</div>
|
||||
<div class="album-artist">${album.artists?.map(a => a?.name || 'Unknown Artist').join(', ') || 'Unknown Artist'}</div>
|
||||
</div>
|
||||
<button class="download-btn download-btn--circle"
|
||||
data-url="${album.external_urls?.spotify || ''}"
|
||||
data-type="${album.album_type || 'album'}"
|
||||
data-name="${album.name || 'Unknown Album'}"
|
||||
title="Download">
|
||||
<img src="/static/images/download.svg" alt="Download">
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
albumsContainer.appendChild(albumElement);
|
||||
});
|
||||
}
|
||||
|
||||
// Add to the end so it appears at the bottom
|
||||
groupsContainer.appendChild(featuringSection);
|
||||
}
|
||||
}
|
||||
|
||||
const artistHeaderEl = document.getElementById('artist-header');
|
||||
if (artistHeaderEl) artistHeaderEl.classList.remove('hidden');
|
||||
|
||||
const albumsContainerEl = document.getElementById('albums-container');
|
||||
if (albumsContainerEl) albumsContainerEl.classList.remove('hidden');
|
||||
|
||||
// Only attach download listeners if explicit filter is not enabled
|
||||
if (!isExplicitFilterEnabled) {
|
||||
attachDownloadListeners();
|
||||
// Pass the artist URL and name so the group buttons can use the artist download function
|
||||
attachGroupDownloadListeners(artistUrl, artistName);
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners for group downloads using the artist download function
|
||||
function attachGroupDownloadListeners(artistUrl: string, artistName: string) {
|
||||
document.querySelectorAll('.group-download-btn').forEach(btn => {
|
||||
const button = btn as HTMLButtonElement; // Cast to HTMLButtonElement
|
||||
button.addEventListener('click', async (e) => {
|
||||
const target = e.target as HTMLButtonElement | null; // Cast target
|
||||
if (!target) return;
|
||||
|
||||
const groupType = target.dataset.groupType || 'album'; // e.g. "album", "single", "compilation", "appears_on"
|
||||
target.disabled = true;
|
||||
|
||||
// Custom text for the 'appears_on' group
|
||||
const displayType = groupType === 'appears_on' ? 'Featuring Albums' : `${capitalize(groupType)}s`;
|
||||
target.textContent = `Queueing all ${displayType}...`;
|
||||
|
||||
try {
|
||||
// Use our local startDownload function with the group type filter
|
||||
const taskIds = await startDownload(
|
||||
artistUrl,
|
||||
'artist',
|
||||
{ name: artistName || 'Unknown Artist', artist: artistName || 'Unknown Artist' },
|
||||
groupType // Only queue releases of this specific type.
|
||||
);
|
||||
|
||||
// Optionally show number of albums queued
|
||||
const totalQueued = Array.isArray(taskIds) ? taskIds.length : 0;
|
||||
target.textContent = `Queued all ${displayType}`;
|
||||
target.title = `${totalQueued} albums queued for download`;
|
||||
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error: any) { // Add type for error
|
||||
target.textContent = `Download All ${displayType}`;
|
||||
target.disabled = false;
|
||||
showError(`Failed to queue download for all ${groupType}: ${error?.message || 'Unknown error'}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Individual download handlers remain unchanged.
|
||||
function attachDownloadListeners() {
|
||||
document.querySelectorAll('.download-btn:not(.group-download-btn)').forEach(btn => {
|
||||
const button = btn as HTMLButtonElement; // Cast to HTMLButtonElement
|
||||
button.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const currentTarget = e.currentTarget as HTMLButtonElement | null; // Cast currentTarget
|
||||
if (!currentTarget) return;
|
||||
|
||||
const url = currentTarget.dataset.url || '';
|
||||
const name = currentTarget.dataset.name || 'Unknown';
|
||||
// Always use 'album' type for individual album downloads regardless of category
|
||||
const type = 'album';
|
||||
|
||||
currentTarget.remove();
|
||||
// Use the centralized downloadQueue.download method
|
||||
downloadQueue.download(url, type, { name, type })
|
||||
.catch((err: any) => showError('Download failed: ' + (err?.message || 'Unknown error'))); // Add type for err
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add startDownload function (similar to track.js and main.js)
|
||||
/**
|
||||
* Starts the download process via centralized download queue
|
||||
*/
|
||||
async function startDownload(url: string, type: string, item: { name: string, artist?: string, type?: string }, albumType?: string) {
|
||||
if (!url || !type) {
|
||||
showError('Missing URL or type for download');
|
||||
return Promise.reject(new Error('Missing URL or type for download')); // Return a rejected promise
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the centralized downloadQueue.download method for all downloads including artist downloads
|
||||
const result = await downloadQueue.download(url, type, item, albumType);
|
||||
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
|
||||
// Return the result for tracking
|
||||
return result;
|
||||
} catch (error: any) { // Add type for error
|
||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// UI Helpers
|
||||
function showError(message: string) {
|
||||
const errorEl = document.getElementById('error');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message || 'An error occurred';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function capitalize(str: string) {
|
||||
return str ? str.charAt(0).toUpperCase() + str.slice(1) : '';
|
||||
}
|
||||
@@ -1,763 +0,0 @@
|
||||
import { downloadQueue } from './queue.js';
|
||||
|
||||
const serviceConfig = {
|
||||
spotify: {
|
||||
fields: [
|
||||
{ id: 'username', label: 'Username', type: 'text' },
|
||||
{ id: 'credentials', label: 'Credentials', type: 'text' }
|
||||
],
|
||||
validator: (data) => ({
|
||||
username: data.username,
|
||||
credentials: data.credentials
|
||||
}),
|
||||
// Adding search credentials fields
|
||||
searchFields: [
|
||||
{ id: 'client_id', label: 'Client ID', type: 'text' },
|
||||
{ id: 'client_secret', label: 'Client Secret', type: 'password' }
|
||||
],
|
||||
searchValidator: (data) => ({
|
||||
client_id: data.client_id,
|
||||
client_secret: data.client_secret
|
||||
})
|
||||
},
|
||||
deezer: {
|
||||
fields: [
|
||||
{ id: 'arl', label: 'ARL', type: 'text' }
|
||||
],
|
||||
validator: (data) => ({
|
||||
arl: data.arl
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let currentService = 'spotify';
|
||||
let currentCredential = null;
|
||||
let isEditingSearch = false;
|
||||
|
||||
// Global variables to hold the active accounts from the config response.
|
||||
let activeSpotifyAccount = '';
|
||||
let activeDeezerAccount = '';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
await initConfig();
|
||||
setupServiceTabs();
|
||||
setupEventListeners();
|
||||
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => {
|
||||
downloadQueue.toggleVisibility();
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showConfigError(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function initConfig() {
|
||||
await loadConfig();
|
||||
await updateAccountSelectors();
|
||||
loadCredentials(currentService);
|
||||
updateFormFields();
|
||||
}
|
||||
|
||||
function setupServiceTabs() {
|
||||
const serviceTabs = document.querySelectorAll('.tab-button');
|
||||
serviceTabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
serviceTabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
currentService = tab.dataset.service;
|
||||
loadCredentials(currentService);
|
||||
updateFormFields();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
document.getElementById('credentialForm').addEventListener('submit', handleCredentialSubmit);
|
||||
|
||||
// Config change listeners
|
||||
document.getElementById('defaultServiceSelect').addEventListener('change', function() {
|
||||
updateServiceSpecificOptions();
|
||||
saveConfig();
|
||||
});
|
||||
document.getElementById('fallbackToggle').addEventListener('change', saveConfig);
|
||||
document.getElementById('realTimeToggle').addEventListener('change', saveConfig);
|
||||
document.getElementById('spotifyQualitySelect').addEventListener('change', saveConfig);
|
||||
document.getElementById('deezerQualitySelect').addEventListener('change', saveConfig);
|
||||
document.getElementById('tracknumPaddingToggle').addEventListener('change', saveConfig);
|
||||
document.getElementById('maxRetries').addEventListener('change', saveConfig);
|
||||
document.getElementById('retryDelaySeconds').addEventListener('change', saveConfig);
|
||||
|
||||
// Update active account globals when the account selector is changed.
|
||||
document.getElementById('spotifyAccountSelect').addEventListener('change', (e) => {
|
||||
activeSpotifyAccount = e.target.value;
|
||||
saveConfig();
|
||||
});
|
||||
document.getElementById('deezerAccountSelect').addEventListener('change', (e) => {
|
||||
activeDeezerAccount = e.target.value;
|
||||
saveConfig();
|
||||
});
|
||||
|
||||
// Formatting settings
|
||||
document.getElementById('customDirFormat').addEventListener('change', saveConfig);
|
||||
document.getElementById('customTrackFormat').addEventListener('change', saveConfig);
|
||||
|
||||
// Copy to clipboard when selecting placeholders
|
||||
document.getElementById('dirFormatHelp').addEventListener('change', function() {
|
||||
copyPlaceholderToClipboard(this);
|
||||
});
|
||||
document.getElementById('trackFormatHelp').addEventListener('change', function() {
|
||||
copyPlaceholderToClipboard(this);
|
||||
});
|
||||
|
||||
// Max concurrent downloads change listener
|
||||
document.getElementById('maxConcurrentDownloads').addEventListener('change', saveConfig);
|
||||
}
|
||||
|
||||
function updateServiceSpecificOptions() {
|
||||
// Get the selected service
|
||||
const selectedService = document.getElementById('defaultServiceSelect').value;
|
||||
|
||||
// Get all service-specific sections
|
||||
const spotifyOptions = document.querySelectorAll('.config-item.spotify-specific');
|
||||
const deezerOptions = document.querySelectorAll('.config-item.deezer-specific');
|
||||
|
||||
// Handle Spotify specific options
|
||||
if (selectedService === 'spotify') {
|
||||
// Highlight Spotify section
|
||||
document.getElementById('spotifyQualitySelect').closest('.config-item').classList.add('highlighted-option');
|
||||
document.getElementById('spotifyAccountSelect').closest('.config-item').classList.add('highlighted-option');
|
||||
|
||||
// Remove highlight from Deezer
|
||||
document.getElementById('deezerQualitySelect').closest('.config-item').classList.remove('highlighted-option');
|
||||
document.getElementById('deezerAccountSelect').closest('.config-item').classList.remove('highlighted-option');
|
||||
}
|
||||
// Handle Deezer specific options (for future use)
|
||||
else if (selectedService === 'deezer') {
|
||||
// Highlight Deezer section
|
||||
document.getElementById('deezerQualitySelect').closest('.config-item').classList.add('highlighted-option');
|
||||
document.getElementById('deezerAccountSelect').closest('.config-item').classList.add('highlighted-option');
|
||||
|
||||
// Remove highlight from Spotify
|
||||
document.getElementById('spotifyQualitySelect').closest('.config-item').classList.remove('highlighted-option');
|
||||
document.getElementById('spotifyAccountSelect').closest('.config-item').classList.remove('highlighted-option');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAccountSelectors() {
|
||||
try {
|
||||
const [spotifyResponse, deezerResponse] = await Promise.all([
|
||||
fetch('/api/credentials/spotify'),
|
||||
fetch('/api/credentials/deezer')
|
||||
]);
|
||||
|
||||
const spotifyAccounts = await spotifyResponse.json();
|
||||
const deezerAccounts = await deezerResponse.json();
|
||||
|
||||
// Get the select elements
|
||||
const spotifySelect = document.getElementById('spotifyAccountSelect');
|
||||
const deezerSelect = document.getElementById('deezerAccountSelect');
|
||||
|
||||
// Rebuild the Spotify selector options
|
||||
spotifySelect.innerHTML = spotifyAccounts
|
||||
.map(a => `<option value="${a}">${a}</option>`)
|
||||
.join('');
|
||||
|
||||
// Use the active account loaded from the config (activeSpotifyAccount)
|
||||
if (spotifyAccounts.includes(activeSpotifyAccount)) {
|
||||
spotifySelect.value = activeSpotifyAccount;
|
||||
} else if (spotifyAccounts.length > 0) {
|
||||
spotifySelect.value = spotifyAccounts[0];
|
||||
activeSpotifyAccount = spotifyAccounts[0];
|
||||
await saveConfig();
|
||||
}
|
||||
|
||||
// Rebuild the Deezer selector options
|
||||
deezerSelect.innerHTML = deezerAccounts
|
||||
.map(a => `<option value="${a}">${a}</option>`)
|
||||
.join('');
|
||||
|
||||
if (deezerAccounts.includes(activeDeezerAccount)) {
|
||||
deezerSelect.value = activeDeezerAccount;
|
||||
} else if (deezerAccounts.length > 0) {
|
||||
deezerSelect.value = deezerAccounts[0];
|
||||
activeDeezerAccount = deezerAccounts[0];
|
||||
await saveConfig();
|
||||
}
|
||||
|
||||
// Handle empty account lists
|
||||
[spotifySelect, deezerSelect].forEach((select, index) => {
|
||||
const accounts = index === 0 ? spotifyAccounts : deezerAccounts;
|
||||
if (accounts.length === 0) {
|
||||
select.innerHTML = '<option value="">No accounts available</option>';
|
||||
select.value = '';
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
showConfigError('Error updating accounts: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCredentials(service) {
|
||||
try {
|
||||
const response = await fetch(`/api/credentials/all/${service}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load credentials: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const credentials = await response.json();
|
||||
renderCredentialsList(service, credentials);
|
||||
} catch (error) {
|
||||
showConfigError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCredentialsList(service, credentials) {
|
||||
const list = document.querySelector('.credentials-list');
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!credentials.length) {
|
||||
list.innerHTML = '<div class="no-credentials">No accounts found. Add a new account below.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
credentials.forEach(credData => {
|
||||
const credItem = document.createElement('div');
|
||||
credItem.className = 'credential-item';
|
||||
|
||||
const hasSearchCreds = credData.search && Object.keys(credData.search).length > 0;
|
||||
|
||||
credItem.innerHTML = `
|
||||
<div class="credential-info">
|
||||
<span class="credential-name">${credData.name}</span>
|
||||
${service === 'spotify' ?
|
||||
`<div class="search-credentials-status ${hasSearchCreds ? 'has-api' : 'no-api'}">
|
||||
${hasSearchCreds ? 'API Configured' : 'No API Credentials'}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<div class="credential-actions">
|
||||
<button class="edit-btn" data-name="${credData.name}" data-service="${service}">Edit Account</button>
|
||||
${service === 'spotify' ?
|
||||
`<button class="edit-search-btn" data-name="${credData.name}" data-service="${service}">
|
||||
${hasSearchCreds ? 'Edit API' : 'Add API'}
|
||||
</button>` : ''}
|
||||
<button class="delete-btn" data-name="${credData.name}" data-service="${service}">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
list.appendChild(credItem);
|
||||
});
|
||||
|
||||
// Set up event handlers
|
||||
list.querySelectorAll('.delete-btn').forEach(btn => {
|
||||
btn.addEventListener('click', handleDeleteCredential);
|
||||
});
|
||||
|
||||
list.querySelectorAll('.edit-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
isEditingSearch = false;
|
||||
handleEditCredential(e);
|
||||
});
|
||||
});
|
||||
|
||||
if (service === 'spotify') {
|
||||
list.querySelectorAll('.edit-search-btn').forEach(btn => {
|
||||
btn.addEventListener('click', handleEditSearchCredential);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteCredential(e) {
|
||||
try {
|
||||
const service = e.target.dataset.service;
|
||||
const name = e.target.dataset.name;
|
||||
|
||||
if (!service || !name) {
|
||||
throw new Error('Missing credential information');
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete the ${name} account?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/credentials/${service}/${name}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete credential');
|
||||
}
|
||||
|
||||
// If the deleted credential is the active account, clear the selection.
|
||||
const accountSelect = document.getElementById(`${service}AccountSelect`);
|
||||
if (accountSelect.value === name) {
|
||||
accountSelect.value = '';
|
||||
if (service === 'spotify') {
|
||||
activeSpotifyAccount = '';
|
||||
} else if (service === 'deezer') {
|
||||
activeDeezerAccount = '';
|
||||
}
|
||||
await saveConfig();
|
||||
}
|
||||
|
||||
loadCredentials(service);
|
||||
await updateAccountSelectors();
|
||||
} catch (error) {
|
||||
showConfigError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditCredential(e) {
|
||||
const service = e.target.dataset.service;
|
||||
const name = e.target.dataset.name;
|
||||
|
||||
try {
|
||||
document.querySelector(`[data-service="${service}"]`).click();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
const response = await fetch(`/api/credentials/${service}/${name}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load credential: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
currentCredential = name;
|
||||
document.getElementById('credentialName').value = name;
|
||||
document.getElementById('credentialName').disabled = true;
|
||||
document.getElementById('formTitle').textContent = `Edit ${service.charAt(0).toUpperCase() + service.slice(1)} Account`;
|
||||
document.getElementById('submitCredentialBtn').textContent = 'Update Account';
|
||||
|
||||
// Show regular fields
|
||||
populateFormFields(service, data);
|
||||
toggleSearchFieldsVisibility(false);
|
||||
} catch (error) {
|
||||
showConfigError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditSearchCredential(e) {
|
||||
const service = e.target.dataset.service;
|
||||
const name = e.target.dataset.name;
|
||||
|
||||
try {
|
||||
if (service !== 'spotify') {
|
||||
throw new Error('Search credentials are only available for Spotify');
|
||||
}
|
||||
|
||||
document.querySelector(`[data-service="${service}"]`).click();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
isEditingSearch = true;
|
||||
currentCredential = name;
|
||||
document.getElementById('credentialName').value = name;
|
||||
document.getElementById('credentialName').disabled = true;
|
||||
document.getElementById('formTitle').textContent = `Spotify API Credentials for ${name}`;
|
||||
document.getElementById('submitCredentialBtn').textContent = 'Save API Credentials';
|
||||
|
||||
// Try to load existing search credentials
|
||||
try {
|
||||
const searchResponse = await fetch(`/api/credentials/${service}/${name}?type=search`);
|
||||
if (searchResponse.ok) {
|
||||
const searchData = await searchResponse.json();
|
||||
// Populate search fields
|
||||
serviceConfig[service].searchFields.forEach(field => {
|
||||
const element = document.getElementById(field.id);
|
||||
if (element) element.value = searchData[field.id] || '';
|
||||
});
|
||||
} else {
|
||||
// Clear search fields if no existing search credentials
|
||||
serviceConfig[service].searchFields.forEach(field => {
|
||||
const element = document.getElementById(field.id);
|
||||
if (element) element.value = '';
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Clear search fields if there was an error
|
||||
serviceConfig[service].searchFields.forEach(field => {
|
||||
const element = document.getElementById(field.id);
|
||||
if (element) element.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
// Hide regular account fields, show search fields
|
||||
toggleSearchFieldsVisibility(true);
|
||||
} catch (error) {
|
||||
showConfigError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSearchFieldsVisibility(showSearchFields) {
|
||||
const serviceFieldsDiv = document.getElementById('serviceFields');
|
||||
const searchFieldsDiv = document.getElementById('searchFields');
|
||||
|
||||
if (showSearchFields) {
|
||||
// Hide regular fields and remove 'required' attribute
|
||||
serviceFieldsDiv.style.display = 'none';
|
||||
// Remove required attribute from service fields
|
||||
serviceConfig[currentService].fields.forEach(field => {
|
||||
const input = document.getElementById(field.id);
|
||||
if (input) input.removeAttribute('required');
|
||||
});
|
||||
|
||||
// Show search fields and add 'required' attribute
|
||||
searchFieldsDiv.style.display = 'block';
|
||||
// Make search fields required
|
||||
if (currentService === 'spotify' && serviceConfig[currentService].searchFields) {
|
||||
serviceConfig[currentService].searchFields.forEach(field => {
|
||||
const input = document.getElementById(field.id);
|
||||
if (input) input.setAttribute('required', '');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Show regular fields and add 'required' attribute
|
||||
serviceFieldsDiv.style.display = 'block';
|
||||
// Make service fields required
|
||||
serviceConfig[currentService].fields.forEach(field => {
|
||||
const input = document.getElementById(field.id);
|
||||
if (input) input.setAttribute('required', '');
|
||||
});
|
||||
|
||||
// Hide search fields and remove 'required' attribute
|
||||
searchFieldsDiv.style.display = 'none';
|
||||
// Remove required from search fields
|
||||
if (currentService === 'spotify' && serviceConfig[currentService].searchFields) {
|
||||
serviceConfig[currentService].searchFields.forEach(field => {
|
||||
const input = document.getElementById(field.id);
|
||||
if (input) input.removeAttribute('required');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateFormFields() {
|
||||
const serviceFieldsDiv = document.getElementById('serviceFields');
|
||||
const searchFieldsDiv = document.getElementById('searchFields');
|
||||
|
||||
// Clear any existing fields
|
||||
serviceFieldsDiv.innerHTML = '';
|
||||
searchFieldsDiv.innerHTML = '';
|
||||
|
||||
// Add regular account fields
|
||||
serviceConfig[currentService].fields.forEach(field => {
|
||||
const fieldDiv = document.createElement('div');
|
||||
fieldDiv.className = 'form-group';
|
||||
fieldDiv.innerHTML = `
|
||||
<label>${field.label}:</label>
|
||||
<input type="${field.type}"
|
||||
id="${field.id}"
|
||||
name="${field.id}"
|
||||
required
|
||||
${field.type === 'password' ? 'autocomplete="new-password"' : ''}>
|
||||
`;
|
||||
serviceFieldsDiv.appendChild(fieldDiv);
|
||||
});
|
||||
|
||||
// Add search fields for Spotify
|
||||
if (currentService === 'spotify' && serviceConfig[currentService].searchFields) {
|
||||
serviceConfig[currentService].searchFields.forEach(field => {
|
||||
const fieldDiv = document.createElement('div');
|
||||
fieldDiv.className = 'form-group';
|
||||
fieldDiv.innerHTML = `
|
||||
<label>${field.label}:</label>
|
||||
<input type="${field.type}"
|
||||
id="${field.id}"
|
||||
name="${field.id}"
|
||||
required
|
||||
${field.type === 'password' ? 'autocomplete="new-password"' : ''}>
|
||||
`;
|
||||
searchFieldsDiv.appendChild(fieldDiv);
|
||||
});
|
||||
}
|
||||
|
||||
// Reset form title and button text
|
||||
document.getElementById('formTitle').textContent = `Add New ${currentService.charAt(0).toUpperCase() + currentService.slice(1)} Account`;
|
||||
document.getElementById('submitCredentialBtn').textContent = 'Save Account';
|
||||
|
||||
// Initially show regular fields, hide search fields
|
||||
toggleSearchFieldsVisibility(false);
|
||||
isEditingSearch = false;
|
||||
}
|
||||
|
||||
function populateFormFields(service, data) {
|
||||
serviceConfig[service].fields.forEach(field => {
|
||||
const element = document.getElementById(field.id);
|
||||
if (element) element.value = data[field.id] || '';
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCredentialSubmit(e) {
|
||||
e.preventDefault();
|
||||
const service = document.querySelector('.tab-button.active').dataset.service;
|
||||
const nameInput = document.getElementById('credentialName');
|
||||
const name = nameInput.value.trim();
|
||||
|
||||
try {
|
||||
if (!currentCredential && !name) {
|
||||
throw new Error('Credential name is required');
|
||||
}
|
||||
|
||||
const endpointName = currentCredential || name;
|
||||
let method, data, endpoint;
|
||||
|
||||
if (isEditingSearch && service === 'spotify') {
|
||||
// Handle search credentials
|
||||
const formData = {};
|
||||
let isValid = true;
|
||||
let firstInvalidField = null;
|
||||
|
||||
// Manually validate search fields
|
||||
serviceConfig[service].searchFields.forEach(field => {
|
||||
const input = document.getElementById(field.id);
|
||||
const value = input ? input.value.trim() : '';
|
||||
formData[field.id] = value;
|
||||
|
||||
if (!value) {
|
||||
isValid = false;
|
||||
if (!firstInvalidField) firstInvalidField = input;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
if (firstInvalidField) firstInvalidField.focus();
|
||||
throw new Error('All fields are required');
|
||||
}
|
||||
|
||||
data = serviceConfig[service].searchValidator(formData);
|
||||
endpoint = `/api/credentials/${service}/${endpointName}?type=search`;
|
||||
|
||||
// Check if search credentials already exist for this account
|
||||
const checkResponse = await fetch(endpoint);
|
||||
method = checkResponse.ok ? 'PUT' : 'POST';
|
||||
} else {
|
||||
// Handle regular account credentials
|
||||
const formData = {};
|
||||
let isValid = true;
|
||||
let firstInvalidField = null;
|
||||
|
||||
// Manually validate account fields
|
||||
serviceConfig[service].fields.forEach(field => {
|
||||
const input = document.getElementById(field.id);
|
||||
const value = input ? input.value.trim() : '';
|
||||
formData[field.id] = value;
|
||||
|
||||
if (!value) {
|
||||
isValid = false;
|
||||
if (!firstInvalidField) firstInvalidField = input;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
if (firstInvalidField) firstInvalidField.focus();
|
||||
throw new Error('All fields are required');
|
||||
}
|
||||
|
||||
data = serviceConfig[service].validator(formData);
|
||||
endpoint = `/api/credentials/${service}/${endpointName}`;
|
||||
method = currentCredential ? 'PUT' : 'POST';
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to save credentials');
|
||||
}
|
||||
|
||||
await updateAccountSelectors();
|
||||
await saveConfig();
|
||||
loadCredentials(service);
|
||||
resetForm();
|
||||
|
||||
// Show success message
|
||||
showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully');
|
||||
} catch (error) {
|
||||
showConfigError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
currentCredential = null;
|
||||
isEditingSearch = false;
|
||||
const nameInput = document.getElementById('credentialName');
|
||||
nameInput.value = '';
|
||||
nameInput.disabled = false;
|
||||
document.getElementById('credentialForm').reset();
|
||||
|
||||
// Reset form title and button text
|
||||
const service = currentService.charAt(0).toUpperCase() + currentService.slice(1);
|
||||
document.getElementById('formTitle').textContent = `Add New ${service} Account`;
|
||||
document.getElementById('submitCredentialBtn').textContent = 'Save Account';
|
||||
|
||||
// Show regular account fields, hide search fields
|
||||
toggleSearchFieldsVisibility(false);
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
// Read active account values directly from the DOM (or from the globals which are kept in sync)
|
||||
const config = {
|
||||
service: document.getElementById('defaultServiceSelect').value,
|
||||
spotify: document.getElementById('spotifyAccountSelect').value,
|
||||
deezer: document.getElementById('deezerAccountSelect').value,
|
||||
fallback: document.getElementById('fallbackToggle').checked,
|
||||
spotifyQuality: document.getElementById('spotifyQualitySelect').value,
|
||||
deezerQuality: document.getElementById('deezerQualitySelect').value,
|
||||
realTime: document.getElementById('realTimeToggle').checked,
|
||||
customDirFormat: document.getElementById('customDirFormat').value,
|
||||
customTrackFormat: document.getElementById('customTrackFormat').value,
|
||||
maxConcurrentDownloads: parseInt(document.getElementById('maxConcurrentDownloads').value, 10) || 3,
|
||||
maxRetries: parseInt(document.getElementById('maxRetries').value, 10) || 3,
|
||||
retryDelaySeconds: parseInt(document.getElementById('retryDelaySeconds').value, 10) || 5,
|
||||
retry_delay_increase: parseInt(document.getElementById('retryDelayIncrease').value, 10) || 5,
|
||||
tracknum_padding: document.getElementById('tracknumPaddingToggle').checked
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to save config');
|
||||
}
|
||||
} catch (error) {
|
||||
showConfigError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
if (!response.ok) throw new Error('Failed to load config');
|
||||
|
||||
const savedConfig = await response.json();
|
||||
|
||||
// Set default service selection
|
||||
document.getElementById('defaultServiceSelect').value = savedConfig.service || 'spotify';
|
||||
|
||||
// Update the service-specific options based on selected service
|
||||
updateServiceSpecificOptions();
|
||||
|
||||
// Use the "spotify" and "deezer" properties from the API response to set the active accounts.
|
||||
activeSpotifyAccount = savedConfig.spotify || '';
|
||||
activeDeezerAccount = savedConfig.deezer || '';
|
||||
|
||||
// (Optionally, if the account selects already exist you can set their values here,
|
||||
// but updateAccountSelectors() will rebuild the options and set the proper values.)
|
||||
const spotifySelect = document.getElementById('spotifyAccountSelect');
|
||||
const deezerSelect = document.getElementById('deezerAccountSelect');
|
||||
if (spotifySelect) spotifySelect.value = activeSpotifyAccount;
|
||||
if (deezerSelect) deezerSelect.value = activeDeezerAccount;
|
||||
|
||||
// Update other configuration fields.
|
||||
document.getElementById('fallbackToggle').checked = !!savedConfig.fallback;
|
||||
document.getElementById('spotifyQualitySelect').value = savedConfig.spotifyQuality || 'NORMAL';
|
||||
document.getElementById('deezerQualitySelect').value = savedConfig.deezerQuality || 'MP3_128';
|
||||
document.getElementById('realTimeToggle').checked = !!savedConfig.realTime;
|
||||
document.getElementById('customDirFormat').value = savedConfig.customDirFormat || '%ar_album%/%album%';
|
||||
document.getElementById('customTrackFormat').value = savedConfig.customTrackFormat || '%tracknum%. %music%';
|
||||
document.getElementById('maxConcurrentDownloads').value = savedConfig.maxConcurrentDownloads || '3';
|
||||
document.getElementById('maxRetries').value = savedConfig.maxRetries || '3';
|
||||
document.getElementById('retryDelaySeconds').value = savedConfig.retryDelaySeconds || '5';
|
||||
document.getElementById('retryDelayIncrease').value = savedConfig.retry_delay_increase || '5';
|
||||
document.getElementById('tracknumPaddingToggle').checked = savedConfig.tracknum_padding === undefined ? true : !!savedConfig.tracknum_padding;
|
||||
|
||||
// Update explicit filter status
|
||||
updateExplicitFilterStatus(savedConfig.explicitFilter);
|
||||
} catch (error) {
|
||||
showConfigError('Error loading config: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updateExplicitFilterStatus(isEnabled) {
|
||||
const statusElement = document.getElementById('explicitFilterStatus');
|
||||
if (statusElement) {
|
||||
// Remove existing classes
|
||||
statusElement.classList.remove('enabled', 'disabled');
|
||||
|
||||
// Add appropriate class and text based on whether filter is enabled
|
||||
if (isEnabled) {
|
||||
statusElement.textContent = 'Enabled';
|
||||
statusElement.classList.add('enabled');
|
||||
} else {
|
||||
statusElement.textContent = 'Disabled';
|
||||
statusElement.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showConfigError(message) {
|
||||
const errorDiv = document.getElementById('configError');
|
||||
errorDiv.textContent = message;
|
||||
setTimeout(() => (errorDiv.textContent = ''), 5000);
|
||||
}
|
||||
|
||||
function showConfigSuccess(message) {
|
||||
const successDiv = document.getElementById('configSuccess');
|
||||
successDiv.textContent = message;
|
||||
setTimeout(() => (successDiv.textContent = ''), 5000);
|
||||
}
|
||||
|
||||
// Function to copy the selected placeholder to clipboard
|
||||
function copyPlaceholderToClipboard(select) {
|
||||
const placeholder = select.value;
|
||||
|
||||
if (!placeholder) return; // If nothing selected
|
||||
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(placeholder)
|
||||
.then(() => {
|
||||
// Show success notification
|
||||
showCopyNotification(`Copied ${placeholder} to clipboard`);
|
||||
|
||||
// Reset select to default after a short delay
|
||||
setTimeout(() => {
|
||||
select.selectedIndex = 0;
|
||||
}, 500);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Function to show a notification when copying
|
||||
function showCopyNotification(message) {
|
||||
// Check if notification container exists, create if not
|
||||
let notificationContainer = document.getElementById('copyNotificationContainer');
|
||||
if (!notificationContainer) {
|
||||
notificationContainer = document.createElement('div');
|
||||
notificationContainer.id = 'copyNotificationContainer';
|
||||
document.body.appendChild(notificationContainer);
|
||||
}
|
||||
|
||||
// Create notification element
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'copy-notification';
|
||||
notification.textContent = message;
|
||||
|
||||
// Add to container
|
||||
notificationContainer.appendChild(notification);
|
||||
|
||||
// Trigger animation
|
||||
setTimeout(() => {
|
||||
notification.classList.add('show');
|
||||
}, 10);
|
||||
|
||||
// Remove after animation completes
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
notificationContainer.removeChild(notification);
|
||||
}, 300);
|
||||
}, 2000);
|
||||
}
|
||||
841
static/js/config.ts
Normal file
841
static/js/config.ts
Normal file
@@ -0,0 +1,841 @@
|
||||
import { downloadQueue } from './queue.js';
|
||||
|
||||
const serviceConfig: Record<string, any> = {
|
||||
spotify: {
|
||||
fields: [
|
||||
{ id: 'username', label: 'Username', type: 'text' },
|
||||
{ id: 'credentials', label: 'Credentials', type: 'text' }
|
||||
],
|
||||
validator: (data) => ({
|
||||
username: data.username,
|
||||
credentials: data.credentials
|
||||
}),
|
||||
// Adding search credentials fields
|
||||
searchFields: [
|
||||
{ id: 'client_id', label: 'Client ID', type: 'text' },
|
||||
{ id: 'client_secret', label: 'Client Secret', type: 'password' }
|
||||
],
|
||||
searchValidator: (data) => ({
|
||||
client_id: data.client_id,
|
||||
client_secret: data.client_secret
|
||||
})
|
||||
},
|
||||
deezer: {
|
||||
fields: [
|
||||
{ id: 'arl', label: 'ARL', type: 'text' }
|
||||
],
|
||||
validator: (data) => ({
|
||||
arl: data.arl
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let currentService = 'spotify';
|
||||
let currentCredential: string | null = null;
|
||||
let isEditingSearch = false;
|
||||
|
||||
// Global variables to hold the active accounts from the config response.
|
||||
let activeSpotifyAccount = '';
|
||||
let activeDeezerAccount = '';
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
if (!response.ok) throw new Error('Failed to load config');
|
||||
|
||||
const savedConfig = await response.json();
|
||||
|
||||
// Set default service selection
|
||||
const defaultServiceSelect = document.getElementById('defaultServiceSelect') as HTMLSelectElement | null;
|
||||
if (defaultServiceSelect) defaultServiceSelect.value = savedConfig.service || 'spotify';
|
||||
|
||||
// Update the service-specific options based on selected service
|
||||
updateServiceSpecificOptions();
|
||||
|
||||
// Use the "spotify" and "deezer" properties from the API response to set the active accounts.
|
||||
activeSpotifyAccount = savedConfig.spotify || '';
|
||||
activeDeezerAccount = savedConfig.deezer || '';
|
||||
|
||||
// (Optionally, if the account selects already exist you can set their values here,
|
||||
// but updateAccountSelectors() will rebuild the options and set the proper values.)
|
||||
const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null;
|
||||
const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null;
|
||||
if (spotifySelect) spotifySelect.value = activeSpotifyAccount;
|
||||
if (deezerSelect) deezerSelect.value = activeDeezerAccount;
|
||||
|
||||
// Update other configuration fields.
|
||||
const fallbackToggle = document.getElementById('fallbackToggle') as HTMLInputElement | null;
|
||||
if (fallbackToggle) fallbackToggle.checked = !!savedConfig.fallback;
|
||||
const spotifyQualitySelect = document.getElementById('spotifyQualitySelect') as HTMLSelectElement | null;
|
||||
if (spotifyQualitySelect) spotifyQualitySelect.value = savedConfig.spotifyQuality || 'NORMAL';
|
||||
const deezerQualitySelect = document.getElementById('deezerQualitySelect') as HTMLSelectElement | null;
|
||||
if (deezerQualitySelect) deezerQualitySelect.value = savedConfig.deezerQuality || 'MP3_128';
|
||||
const realTimeToggle = document.getElementById('realTimeToggle') as HTMLInputElement | null;
|
||||
if (realTimeToggle) realTimeToggle.checked = !!savedConfig.realTime;
|
||||
const customDirFormat = document.getElementById('customDirFormat') as HTMLInputElement | null;
|
||||
if (customDirFormat) customDirFormat.value = savedConfig.customDirFormat || '%ar_album%/%album%';
|
||||
const customTrackFormat = document.getElementById('customTrackFormat') as HTMLInputElement | null;
|
||||
if (customTrackFormat) customTrackFormat.value = savedConfig.customTrackFormat || '%tracknum%. %music%';
|
||||
const maxConcurrentDownloads = document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null;
|
||||
if (maxConcurrentDownloads) maxConcurrentDownloads.value = savedConfig.maxConcurrentDownloads || '3';
|
||||
const maxRetries = document.getElementById('maxRetries') as HTMLInputElement | null;
|
||||
if (maxRetries) maxRetries.value = savedConfig.maxRetries || '3';
|
||||
const retryDelaySeconds = document.getElementById('retryDelaySeconds') as HTMLInputElement | null;
|
||||
if (retryDelaySeconds) retryDelaySeconds.value = savedConfig.retryDelaySeconds || '5';
|
||||
const retryDelayIncrease = document.getElementById('retryDelayIncrease') as HTMLInputElement | null;
|
||||
if (retryDelayIncrease) retryDelayIncrease.value = savedConfig.retry_delay_increase || '5';
|
||||
const tracknumPaddingToggle = document.getElementById('tracknumPaddingToggle') as HTMLInputElement | null;
|
||||
if (tracknumPaddingToggle) tracknumPaddingToggle.checked = savedConfig.tracknum_padding === undefined ? true : !!savedConfig.tracknum_padding;
|
||||
|
||||
// Update explicit filter status
|
||||
updateExplicitFilterStatus(savedConfig.explicitFilter);
|
||||
} catch (error: any) {
|
||||
showConfigError('Error loading config: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
await initConfig();
|
||||
setupServiceTabs();
|
||||
setupEventListeners();
|
||||
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
if (queueIcon) {
|
||||
queueIcon.addEventListener('click', () => {
|
||||
downloadQueue.toggleVisibility();
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
showConfigError(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function initConfig() {
|
||||
await loadConfig();
|
||||
await updateAccountSelectors();
|
||||
loadCredentials(currentService);
|
||||
updateFormFields();
|
||||
}
|
||||
|
||||
function setupServiceTabs() {
|
||||
const serviceTabs = document.querySelectorAll('.tab-button');
|
||||
serviceTabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
serviceTabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
currentService = (tab as HTMLElement).dataset.service || 'spotify';
|
||||
loadCredentials(currentService);
|
||||
updateFormFields();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
(document.getElementById('credentialForm') as HTMLFormElement | null)?.addEventListener('submit', handleCredentialSubmit);
|
||||
|
||||
// Config change listeners
|
||||
(document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.addEventListener('change', function() {
|
||||
updateServiceSpecificOptions();
|
||||
saveConfig();
|
||||
});
|
||||
(document.getElementById('fallbackToggle') as HTMLInputElement | null)?.addEventListener('change', saveConfig);
|
||||
(document.getElementById('realTimeToggle') as HTMLInputElement | null)?.addEventListener('change', saveConfig);
|
||||
(document.getElementById('spotifyQualitySelect') as HTMLSelectElement | null)?.addEventListener('change', saveConfig);
|
||||
(document.getElementById('deezerQualitySelect') as HTMLSelectElement | null)?.addEventListener('change', saveConfig);
|
||||
(document.getElementById('tracknumPaddingToggle') as HTMLInputElement | null)?.addEventListener('change', saveConfig);
|
||||
(document.getElementById('maxRetries') as HTMLInputElement | null)?.addEventListener('change', saveConfig);
|
||||
(document.getElementById('retryDelaySeconds') as HTMLInputElement | null)?.addEventListener('change', saveConfig);
|
||||
|
||||
// Update active account globals when the account selector is changed.
|
||||
(document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null)?.addEventListener('change', (e: Event) => {
|
||||
activeSpotifyAccount = (e.target as HTMLSelectElement).value;
|
||||
saveConfig();
|
||||
});
|
||||
(document.getElementById('deezerAccountSelect') as HTMLSelectElement | null)?.addEventListener('change', (e: Event) => {
|
||||
activeDeezerAccount = (e.target as HTMLSelectElement).value;
|
||||
saveConfig();
|
||||
});
|
||||
|
||||
// Formatting settings
|
||||
(document.getElementById('customDirFormat') as HTMLInputElement | null)?.addEventListener('change', saveConfig);
|
||||
(document.getElementById('customTrackFormat') as HTMLInputElement | null)?.addEventListener('change', saveConfig);
|
||||
|
||||
// Copy to clipboard when selecting placeholders
|
||||
(document.getElementById('dirFormatHelp') as HTMLSelectElement | null)?.addEventListener('change', function() {
|
||||
copyPlaceholderToClipboard(this as HTMLSelectElement);
|
||||
});
|
||||
(document.getElementById('trackFormatHelp') as HTMLSelectElement | null)?.addEventListener('change', function() {
|
||||
copyPlaceholderToClipboard(this as HTMLSelectElement);
|
||||
});
|
||||
|
||||
// Max concurrent downloads change listener
|
||||
(document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null)?.addEventListener('change', saveConfig);
|
||||
}
|
||||
|
||||
function updateServiceSpecificOptions() {
|
||||
// Get the selected service
|
||||
const selectedService = (document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.value;
|
||||
|
||||
// Get all service-specific sections
|
||||
const spotifyOptions = document.querySelectorAll('.config-item.spotify-specific');
|
||||
const deezerOptions = document.querySelectorAll('.config-item.deezer-specific');
|
||||
|
||||
// Handle Spotify specific options
|
||||
if (selectedService === 'spotify') {
|
||||
// Highlight Spotify section
|
||||
(document.getElementById('spotifyQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option');
|
||||
(document.getElementById('spotifyAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option');
|
||||
|
||||
// Remove highlight from Deezer
|
||||
(document.getElementById('deezerQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option');
|
||||
(document.getElementById('deezerAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option');
|
||||
}
|
||||
// Handle Deezer specific options (for future use)
|
||||
else if (selectedService === 'deezer') {
|
||||
// Highlight Deezer section
|
||||
(document.getElementById('deezerQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option');
|
||||
(document.getElementById('deezerAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option');
|
||||
|
||||
// Remove highlight from Spotify
|
||||
(document.getElementById('spotifyQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option');
|
||||
(document.getElementById('spotifyAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAccountSelectors() {
|
||||
try {
|
||||
const [spotifyResponse, deezerResponse] = await Promise.all([
|
||||
fetch('/api/credentials/spotify'),
|
||||
fetch('/api/credentials/deezer')
|
||||
]);
|
||||
|
||||
const spotifyAccounts = await spotifyResponse.json();
|
||||
const deezerAccounts = await deezerResponse.json();
|
||||
|
||||
// Get the select elements
|
||||
const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null;
|
||||
const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null;
|
||||
|
||||
// Rebuild the Spotify selector options
|
||||
if (spotifySelect) {
|
||||
spotifySelect.innerHTML = spotifyAccounts
|
||||
.map((a: string) => `<option value="${a}">${a}</option>`)
|
||||
.join('');
|
||||
|
||||
// Use the active account loaded from the config (activeSpotifyAccount)
|
||||
if (spotifyAccounts.includes(activeSpotifyAccount)) {
|
||||
spotifySelect.value = activeSpotifyAccount;
|
||||
} else if (spotifyAccounts.length > 0) {
|
||||
spotifySelect.value = spotifyAccounts[0];
|
||||
activeSpotifyAccount = spotifyAccounts[0];
|
||||
await saveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the Deezer selector options
|
||||
if (deezerSelect) {
|
||||
deezerSelect.innerHTML = deezerAccounts
|
||||
.map((a: string) => `<option value="${a}">${a}</option>`)
|
||||
.join('');
|
||||
|
||||
if (deezerAccounts.includes(activeDeezerAccount)) {
|
||||
deezerSelect.value = activeDeezerAccount;
|
||||
} else if (deezerAccounts.length > 0) {
|
||||
deezerSelect.value = deezerAccounts[0];
|
||||
activeDeezerAccount = deezerAccounts[0];
|
||||
await saveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle empty account lists
|
||||
[spotifySelect, deezerSelect].forEach((select, index) => {
|
||||
const accounts = index === 0 ? spotifyAccounts : deezerAccounts;
|
||||
if (select && accounts.length === 0) {
|
||||
select.innerHTML = '<option value="">No accounts available</option>';
|
||||
select.value = '';
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
showConfigError('Error updating accounts: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCredentials(service: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/credentials/all/${service}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load credentials: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const credentials = await response.json();
|
||||
renderCredentialsList(service, credentials);
|
||||
} catch (error: any) {
|
||||
showConfigError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCredentialsList(service: string, credentials: any[]) {
|
||||
const list = document.querySelector('.credentials-list') as HTMLElement | null;
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!credentials.length) {
|
||||
list.innerHTML = '<div class="no-credentials">No accounts found. Add a new account below.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
credentials.forEach(credData => {
|
||||
const credItem = document.createElement('div');
|
||||
credItem.className = 'credential-item';
|
||||
|
||||
const hasSearchCreds = credData.search && Object.keys(credData.search).length > 0;
|
||||
|
||||
credItem.innerHTML = `
|
||||
<div class="credential-info">
|
||||
<span class="credential-name">${credData.name}</span>
|
||||
${service === 'spotify' ?
|
||||
`<div class="search-credentials-status ${hasSearchCreds ? 'has-api' : 'no-api'}">
|
||||
${hasSearchCreds ? 'API Configured' : 'No API Credentials'}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<div class="credential-actions">
|
||||
<button class="edit-btn" data-name="${credData.name}" data-service="${service}">Edit Account</button>
|
||||
${service === 'spotify' ?
|
||||
`<button class="edit-search-btn" data-name="${credData.name}" data-service="${service}">
|
||||
${hasSearchCreds ? 'Edit API' : 'Add API'}
|
||||
</button>` : ''}
|
||||
<button class="delete-btn" data-name="${credData.name}" data-service="${service}">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
list.appendChild(credItem);
|
||||
});
|
||||
|
||||
// Set up event handlers
|
||||
list.querySelectorAll('.delete-btn').forEach(btn => {
|
||||
btn.addEventListener('click', handleDeleteCredential as EventListener);
|
||||
});
|
||||
|
||||
list.querySelectorAll('.edit-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
isEditingSearch = false;
|
||||
handleEditCredential(e as MouseEvent);
|
||||
});
|
||||
});
|
||||
|
||||
if (service === 'spotify') {
|
||||
list.querySelectorAll('.edit-search-btn').forEach(btn => {
|
||||
btn.addEventListener('click', handleEditSearchCredential as EventListener);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteCredential(e: Event) {
|
||||
try {
|
||||
const target = e.target as HTMLElement;
|
||||
const service = target.dataset.service;
|
||||
const name = target.dataset.name;
|
||||
|
||||
if (!service || !name) {
|
||||
throw new Error('Missing credential information');
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete the ${name} account?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/credentials/${service}/${name}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete credential');
|
||||
}
|
||||
|
||||
// If the deleted credential is the active account, clear the selection.
|
||||
const accountSelect = document.getElementById(`${service}AccountSelect`) as HTMLSelectElement | null;
|
||||
if (accountSelect && accountSelect.value === name) {
|
||||
accountSelect.value = '';
|
||||
if (service === 'spotify') {
|
||||
activeSpotifyAccount = '';
|
||||
} else if (service === 'deezer') {
|
||||
activeDeezerAccount = '';
|
||||
}
|
||||
await saveConfig();
|
||||
}
|
||||
|
||||
loadCredentials(service);
|
||||
await updateAccountSelectors();
|
||||
} catch (error: any) {
|
||||
showConfigError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditCredential(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
const service = target.dataset.service;
|
||||
const name = target.dataset.name;
|
||||
|
||||
try {
|
||||
(document.querySelector(`[data-service="${service}"]`) as HTMLElement | null)?.click();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
const response = await fetch(`/api/credentials/${service}/${name}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load credential: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
currentCredential = name ? name : null;
|
||||
const credentialNameInput = document.getElementById('credentialName') as HTMLInputElement | null;
|
||||
if (credentialNameInput) {
|
||||
credentialNameInput.value = name || '';
|
||||
credentialNameInput.disabled = true;
|
||||
}
|
||||
(document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Edit ${service!.charAt(0).toUpperCase() + service!.slice(1)} Account`;
|
||||
(document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Update Account';
|
||||
|
||||
// Show regular fields
|
||||
populateFormFields(service!, data);
|
||||
toggleSearchFieldsVisibility(false);
|
||||
} catch (error: any) {
|
||||
showConfigError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditSearchCredential(e: Event) {
|
||||
const target = e.target as HTMLElement;
|
||||
const service = target.dataset.service;
|
||||
const name = target.dataset.name;
|
||||
|
||||
try {
|
||||
if (service !== 'spotify') {
|
||||
throw new Error('Search credentials are only available for Spotify');
|
||||
}
|
||||
|
||||
(document.querySelector(`[data-service="${service}"]`) as HTMLElement | null)?.click();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
isEditingSearch = true;
|
||||
currentCredential = name ? name : null;
|
||||
const credentialNameInput = document.getElementById('credentialName') as HTMLInputElement | null;
|
||||
if (credentialNameInput) {
|
||||
credentialNameInput.value = name || '';
|
||||
credentialNameInput.disabled = true;
|
||||
}
|
||||
(document.getElementById('formTitle')as HTMLElement | null)!.textContent = `Spotify API Credentials for ${name}`;
|
||||
(document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Save API Credentials';
|
||||
|
||||
// Try to load existing search credentials
|
||||
try {
|
||||
const searchResponse = await fetch(`/api/credentials/${service}/${name}?type=search`);
|
||||
if (searchResponse.ok) {
|
||||
const searchData = await searchResponse.json();
|
||||
// Populate search fields
|
||||
serviceConfig[service].searchFields.forEach((field: { id: string; }) => {
|
||||
const element = document.getElementById(field.id) as HTMLInputElement | null;
|
||||
if (element) element.value = searchData[field.id] || '';
|
||||
});
|
||||
} else {
|
||||
// Clear search fields if no existing search credentials
|
||||
serviceConfig[service].searchFields.forEach((field: { id: string; }) => {
|
||||
const element = document.getElementById(field.id) as HTMLInputElement | null;
|
||||
if (element) element.value = '';
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Clear search fields if there was an error
|
||||
serviceConfig[service].searchFields.forEach((field: { id: string; }) => {
|
||||
const element = document.getElementById(field.id) as HTMLInputElement | null;
|
||||
if (element) element.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
// Hide regular account fields, show search fields
|
||||
toggleSearchFieldsVisibility(true);
|
||||
} catch (error: any) {
|
||||
showConfigError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSearchFieldsVisibility(showSearchFields: boolean) {
|
||||
const serviceFieldsDiv = document.getElementById('serviceFields') as HTMLElement | null;
|
||||
const searchFieldsDiv = document.getElementById('searchFields') as HTMLElement | null;
|
||||
|
||||
if (showSearchFields) {
|
||||
// Hide regular fields and remove 'required' attribute
|
||||
if(serviceFieldsDiv) serviceFieldsDiv.style.display = 'none';
|
||||
// Remove required attribute from service fields
|
||||
serviceConfig[currentService].fields.forEach((field: { id: string }) => {
|
||||
const input = document.getElementById(field.id) as HTMLInputElement | null;
|
||||
if (input) input.removeAttribute('required');
|
||||
});
|
||||
|
||||
// Show search fields and add 'required' attribute
|
||||
if(searchFieldsDiv) searchFieldsDiv.style.display = 'block';
|
||||
// Make search fields required
|
||||
if (currentService === 'spotify' && serviceConfig[currentService].searchFields) {
|
||||
serviceConfig[currentService].searchFields.forEach((field: { id: string }) => {
|
||||
const input = document.getElementById(field.id) as HTMLInputElement | null;
|
||||
if (input) input.setAttribute('required', '');
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Show regular fields and add 'required' attribute
|
||||
if(serviceFieldsDiv) serviceFieldsDiv.style.display = 'block';
|
||||
// Make service fields required
|
||||
serviceConfig[currentService].fields.forEach((field: { id: string }) => {
|
||||
const input = document.getElementById(field.id) as HTMLInputElement | null;
|
||||
if (input) input.setAttribute('required', '');
|
||||
});
|
||||
|
||||
// Hide search fields and remove 'required' attribute
|
||||
if(searchFieldsDiv) searchFieldsDiv.style.display = 'none';
|
||||
// Remove required from search fields
|
||||
if (currentService === 'spotify' && serviceConfig[currentService].searchFields) {
|
||||
serviceConfig[currentService].searchFields.forEach((field: { id: string }) => {
|
||||
const input = document.getElementById(field.id) as HTMLInputElement | null;
|
||||
if (input) input.removeAttribute('required');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateFormFields() {
|
||||
const serviceFieldsDiv = document.getElementById('serviceFields') as HTMLElement | null;
|
||||
const searchFieldsDiv = document.getElementById('searchFields') as HTMLElement | null;
|
||||
|
||||
// Clear any existing fields
|
||||
if(serviceFieldsDiv) serviceFieldsDiv.innerHTML = '';
|
||||
if(searchFieldsDiv) searchFieldsDiv.innerHTML = '';
|
||||
|
||||
// Add regular account fields
|
||||
serviceConfig[currentService].fields.forEach((field: { className: string; label: string; type: string; id: string; }) => {
|
||||
const fieldDiv = document.createElement('div');
|
||||
fieldDiv.className = 'form-group';
|
||||
fieldDiv.innerHTML = `
|
||||
<label>${field.label}:</label>
|
||||
<input type="${field.type}"
|
||||
id="${field.id}"
|
||||
name="${field.id}"
|
||||
required
|
||||
${field.type === 'password' ? 'autocomplete="new-password"' : ''}>
|
||||
`;
|
||||
serviceFieldsDiv?.appendChild(fieldDiv);
|
||||
});
|
||||
|
||||
// Add search fields for Spotify
|
||||
if (currentService === 'spotify' && serviceConfig[currentService].searchFields) {
|
||||
serviceConfig[currentService].searchFields.forEach((field: { className: string; label: string; type: string; id: string; }) => {
|
||||
const fieldDiv = document.createElement('div');
|
||||
fieldDiv.className = 'form-group';
|
||||
fieldDiv.innerHTML = `
|
||||
<label>${field.label}:</label>
|
||||
<input type="${field.type}"
|
||||
id="${field.id}"
|
||||
name="${field.id}"
|
||||
required
|
||||
${field.type === 'password' ? 'autocomplete="new-password"' : ''}>
|
||||
`;
|
||||
searchFieldsDiv?.appendChild(fieldDiv);
|
||||
});
|
||||
}
|
||||
|
||||
// Reset form title and button text
|
||||
(document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Add New ${currentService.charAt(0).toUpperCase() + currentService.slice(1)} Account`;
|
||||
(document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Save Account';
|
||||
|
||||
// Initially show regular fields, hide search fields
|
||||
toggleSearchFieldsVisibility(false);
|
||||
isEditingSearch = false;
|
||||
}
|
||||
|
||||
function populateFormFields(service: string, data: Record<string, string>) {
|
||||
serviceConfig[service].fields.forEach((field: { id: string; }) => {
|
||||
const element = document.getElementById(field.id) as HTMLInputElement | null;
|
||||
if (element) element.value = data[field.id] || '';
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCredentialSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
const service = (document.querySelector('.tab-button.active') as HTMLElement | null)?.dataset.service;
|
||||
const nameInput = document.getElementById('credentialName') as HTMLInputElement | null;
|
||||
const name = nameInput?.value.trim();
|
||||
|
||||
try {
|
||||
if (!currentCredential && !name) {
|
||||
throw new Error('Credential name is required');
|
||||
}
|
||||
if (!service) {
|
||||
throw new Error('Service not selected');
|
||||
}
|
||||
|
||||
const endpointName = currentCredential || name;
|
||||
let method: string, data: any, endpoint: string;
|
||||
|
||||
if (isEditingSearch && service === 'spotify') {
|
||||
// Handle search credentials
|
||||
const formData: Record<string, string> = {};
|
||||
let isValid = true;
|
||||
let firstInvalidField: HTMLInputElement | null = null;
|
||||
|
||||
// Manually validate search fields
|
||||
serviceConfig[service!].searchFields.forEach((field: { id: string; }) => {
|
||||
const input = document.getElementById(field.id) as HTMLInputElement | null;
|
||||
const value = input ? input.value.trim() : '';
|
||||
formData[field.id] = value;
|
||||
|
||||
if (!value) {
|
||||
isValid = false;
|
||||
if (!firstInvalidField && input) firstInvalidField = input;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
if (firstInvalidField) (firstInvalidField as HTMLInputElement).focus();
|
||||
throw new Error('All fields are required');
|
||||
}
|
||||
|
||||
data = serviceConfig[service!].searchValidator(formData);
|
||||
endpoint = `/api/credentials/${service}/${endpointName}?type=search`;
|
||||
|
||||
// Check if search credentials already exist for this account
|
||||
const checkResponse = await fetch(endpoint);
|
||||
method = checkResponse.ok ? 'PUT' : 'POST';
|
||||
} else {
|
||||
// Handle regular account credentials
|
||||
const formData: Record<string, string> = {};
|
||||
let isValid = true;
|
||||
let firstInvalidField: HTMLInputElement | null = null;
|
||||
|
||||
// Manually validate account fields
|
||||
serviceConfig[service!].fields.forEach((field: { id: string; }) => {
|
||||
const input = document.getElementById(field.id) as HTMLInputElement | null;
|
||||
const value = input ? input.value.trim() : '';
|
||||
formData[field.id] = value;
|
||||
|
||||
if (!value) {
|
||||
isValid = false;
|
||||
if (!firstInvalidField && input) firstInvalidField = input;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
if (firstInvalidField) (firstInvalidField as HTMLInputElement).focus();
|
||||
throw new Error('All fields are required');
|
||||
}
|
||||
|
||||
data = serviceConfig[service!].validator(formData);
|
||||
endpoint = `/api/credentials/${service}/${endpointName}`;
|
||||
method = currentCredential ? 'PUT' : 'POST';
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to save credentials');
|
||||
}
|
||||
|
||||
await updateAccountSelectors();
|
||||
await saveConfig();
|
||||
loadCredentials(service!);
|
||||
resetForm();
|
||||
|
||||
// Show success message
|
||||
showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully');
|
||||
} catch (error: any) {
|
||||
showConfigError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
currentCredential = null;
|
||||
isEditingSearch = false;
|
||||
const nameInput = document.getElementById('credentialName') as HTMLInputElement | null;
|
||||
if (nameInput) {
|
||||
nameInput.value = '';
|
||||
nameInput.disabled = false;
|
||||
}
|
||||
(document.getElementById('credentialForm') as HTMLFormElement | null)?.reset();
|
||||
|
||||
// Reset form title and button text
|
||||
const serviceName = currentService.charAt(0).toUpperCase() + currentService.slice(1);
|
||||
(document.getElementById('formTitle') as HTMLElement | null)!.textContent = `Add New ${serviceName} Account`;
|
||||
(document.getElementById('submitCredentialBtn') as HTMLElement | null)!.textContent = 'Save Account';
|
||||
|
||||
// Show regular account fields, hide search fields
|
||||
toggleSearchFieldsVisibility(false);
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
// Read active account values directly from the DOM (or from the globals which are kept in sync)
|
||||
const config = {
|
||||
service: (document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.value,
|
||||
spotify: (document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null)?.value,
|
||||
deezer: (document.getElementById('deezerAccountSelect') as HTMLSelectElement | null)?.value,
|
||||
fallback: (document.getElementById('fallbackToggle') as HTMLInputElement | null)?.checked,
|
||||
spotifyQuality: (document.getElementById('spotifyQualitySelect') as HTMLSelectElement | null)?.value,
|
||||
deezerQuality: (document.getElementById('deezerQualitySelect') as HTMLSelectElement | null)?.value,
|
||||
realTime: (document.getElementById('realTimeToggle') as HTMLInputElement | null)?.checked,
|
||||
customDirFormat: (document.getElementById('customDirFormat') as HTMLInputElement | null)?.value,
|
||||
customTrackFormat: (document.getElementById('customTrackFormat') as HTMLInputElement | null)?.value,
|
||||
maxConcurrentDownloads: parseInt((document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null)?.value || '3', 10) || 3,
|
||||
maxRetries: parseInt((document.getElementById('maxRetries') as HTMLInputElement | null)?.value || '3', 10) || 3,
|
||||
retryDelaySeconds: parseInt((document.getElementById('retryDelaySeconds') as HTMLInputElement | null)?.value || '5', 10) || 5,
|
||||
retry_delay_increase: parseInt((document.getElementById('retryDelayIncrease') as HTMLInputElement | null)?.value || '5', 10) || 5,
|
||||
tracknum_padding: (document.getElementById('tracknumPaddingToggle') as HTMLInputElement | null)?.checked
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to save config');
|
||||
}
|
||||
|
||||
const savedConfig = await response.json();
|
||||
|
||||
// Set default service selection
|
||||
const defaultServiceSelect = document.getElementById('defaultServiceSelect') as HTMLSelectElement | null;
|
||||
if (defaultServiceSelect) defaultServiceSelect.value = savedConfig.service || 'spotify';
|
||||
|
||||
// Update the service-specific options based on selected service
|
||||
updateServiceSpecificOptions();
|
||||
|
||||
// Use the "spotify" and "deezer" properties from the API response to set the active accounts.
|
||||
activeSpotifyAccount = savedConfig.spotify || '';
|
||||
activeDeezerAccount = savedConfig.deezer || '';
|
||||
|
||||
// (Optionally, if the account selects already exist you can set their values here,
|
||||
// but updateAccountSelectors() will rebuild the options and set the proper values.)
|
||||
const spotifySelect = document.getElementById('spotifyAccountSelect') as HTMLSelectElement | null;
|
||||
const deezerSelect = document.getElementById('deezerAccountSelect') as HTMLSelectElement | null;
|
||||
if (spotifySelect) spotifySelect.value = activeSpotifyAccount;
|
||||
if (deezerSelect) deezerSelect.value = activeDeezerAccount;
|
||||
|
||||
// Update other configuration fields.
|
||||
const fallbackToggle = document.getElementById('fallbackToggle') as HTMLInputElement | null;
|
||||
if (fallbackToggle) fallbackToggle.checked = !!savedConfig.fallback;
|
||||
const spotifyQualitySelect = document.getElementById('spotifyQualitySelect') as HTMLSelectElement | null;
|
||||
if (spotifyQualitySelect) spotifyQualitySelect.value = savedConfig.spotifyQuality || 'NORMAL';
|
||||
const deezerQualitySelect = document.getElementById('deezerQualitySelect') as HTMLSelectElement | null;
|
||||
if (deezerQualitySelect) deezerQualitySelect.value = savedConfig.deezerQuality || 'MP3_128';
|
||||
const realTimeToggle = document.getElementById('realTimeToggle') as HTMLInputElement | null;
|
||||
if (realTimeToggle) realTimeToggle.checked = !!savedConfig.realTime;
|
||||
const customDirFormat = document.getElementById('customDirFormat') as HTMLInputElement | null;
|
||||
if (customDirFormat) customDirFormat.value = savedConfig.customDirFormat || '%ar_album%/%album%';
|
||||
const customTrackFormat = document.getElementById('customTrackFormat') as HTMLInputElement | null;
|
||||
if (customTrackFormat) customTrackFormat.value = savedConfig.customTrackFormat || '%tracknum%. %music%';
|
||||
const maxConcurrentDownloads = document.getElementById('maxConcurrentDownloads') as HTMLInputElement | null;
|
||||
if (maxConcurrentDownloads) maxConcurrentDownloads.value = savedConfig.maxConcurrentDownloads || '3';
|
||||
const maxRetries = document.getElementById('maxRetries') as HTMLInputElement | null;
|
||||
if (maxRetries) maxRetries.value = savedConfig.maxRetries || '3';
|
||||
const retryDelaySeconds = document.getElementById('retryDelaySeconds') as HTMLInputElement | null;
|
||||
if (retryDelaySeconds) retryDelaySeconds.value = savedConfig.retryDelaySeconds || '5';
|
||||
const retryDelayIncrease = document.getElementById('retryDelayIncrease') as HTMLInputElement | null;
|
||||
if (retryDelayIncrease) retryDelayIncrease.value = savedConfig.retry_delay_increase || '5';
|
||||
const tracknumPaddingToggle = document.getElementById('tracknumPaddingToggle') as HTMLInputElement | null;
|
||||
if (tracknumPaddingToggle) tracknumPaddingToggle.checked = savedConfig.tracknum_padding === undefined ? true : !!savedConfig.tracknum_padding;
|
||||
|
||||
// Update explicit filter status
|
||||
updateExplicitFilterStatus(savedConfig.explicitFilter);
|
||||
} catch (error: any) {
|
||||
showConfigError('Error loading config: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updateExplicitFilterStatus(isEnabled: boolean) {
|
||||
const statusElement = document.getElementById('explicitFilterStatus');
|
||||
if (statusElement) {
|
||||
// Remove existing classes
|
||||
statusElement.classList.remove('enabled', 'disabled');
|
||||
|
||||
// Add appropriate class and text based on whether filter is enabled
|
||||
if (isEnabled) {
|
||||
statusElement.textContent = 'Enabled';
|
||||
statusElement.classList.add('enabled');
|
||||
} else {
|
||||
statusElement.textContent = 'Disabled';
|
||||
statusElement.classList.add('disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showConfigError(message: string) {
|
||||
const errorDiv = document.getElementById('configError');
|
||||
if (errorDiv) errorDiv.textContent = message;
|
||||
setTimeout(() => { if (errorDiv) errorDiv.textContent = '' }, 5000);
|
||||
}
|
||||
|
||||
function showConfigSuccess(message: string) {
|
||||
const successDiv = document.getElementById('configSuccess');
|
||||
if (successDiv) successDiv.textContent = message;
|
||||
setTimeout(() => { if (successDiv) successDiv.textContent = '' }, 5000);
|
||||
}
|
||||
|
||||
// Function to copy the selected placeholder to clipboard
|
||||
function copyPlaceholderToClipboard(select: HTMLSelectElement) {
|
||||
const placeholder = select.value;
|
||||
|
||||
if (!placeholder) return; // If nothing selected
|
||||
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(placeholder)
|
||||
.then(() => {
|
||||
// Show success notification
|
||||
showCopyNotification(`Copied ${placeholder} to clipboard`);
|
||||
|
||||
// Reset select to default after a short delay
|
||||
setTimeout(() => {
|
||||
select.selectedIndex = 0;
|
||||
}, 500);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Function to show a notification when copying
|
||||
function showCopyNotification(message: string) {
|
||||
// Check if notification container exists, create if not
|
||||
let notificationContainer = document.getElementById('copyNotificationContainer');
|
||||
if (!notificationContainer) {
|
||||
notificationContainer = document.createElement('div');
|
||||
notificationContainer.id = 'copyNotificationContainer';
|
||||
document.body.appendChild(notificationContainer);
|
||||
}
|
||||
|
||||
// Create notification element
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'copy-notification';
|
||||
notification.textContent = message;
|
||||
|
||||
// Add to container
|
||||
notificationContainer.appendChild(notification);
|
||||
|
||||
// Trigger animation
|
||||
setTimeout(() => {
|
||||
notification.classList.add('show');
|
||||
}, 10);
|
||||
|
||||
// Remove after animation completes
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
notificationContainer.removeChild(notification);
|
||||
}, 300);
|
||||
}, 2000);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
// main.js
|
||||
// main.ts
|
||||
import { downloadQueue } from './queue.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// DOM elements
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const searchButton = document.getElementById('searchButton');
|
||||
const searchType = document.getElementById('searchType');
|
||||
const searchInput = document.getElementById('searchInput') as HTMLInputElement | null;
|
||||
const searchButton = document.getElementById('searchButton') as HTMLButtonElement | null;
|
||||
const searchType = document.getElementById('searchType') as HTMLSelectElement | null;
|
||||
const resultsContainer = document.getElementById('resultsContainer');
|
||||
const queueIcon = document.getElementById('queueIcon');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
@@ -24,18 +24,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keypress', function(e) {
|
||||
searchInput.addEventListener('keypress', function(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-detect and handle pasted Spotify URLs
|
||||
searchInput.addEventListener('input', function(e) {
|
||||
const inputVal = e.target.value.trim();
|
||||
searchInput.addEventListener('input', function(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const inputVal = target.value.trim();
|
||||
if (isSpotifyUrl(inputVal)) {
|
||||
const details = getSpotifyResourceDetails(inputVal);
|
||||
if (details) {
|
||||
if (details && searchType) {
|
||||
searchType.value = details.type;
|
||||
}
|
||||
}
|
||||
@@ -44,7 +45,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Restore last search type if no URL override
|
||||
const savedType = localStorage.getItem('lastSearchType');
|
||||
if (savedType && ['track','album','playlist','artist'].includes(savedType)) {
|
||||
if (searchType && savedType && ['track','album','playlist','artist'].includes(savedType)) {
|
||||
searchType.value = savedType;
|
||||
}
|
||||
// Save last selection on change
|
||||
@@ -59,9 +60,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const query = urlParams.get('q');
|
||||
const type = urlParams.get('type');
|
||||
|
||||
if (query) {
|
||||
if (query && searchInput) {
|
||||
searchInput.value = query;
|
||||
if (type && ['track', 'album', 'playlist', 'artist'].includes(type)) {
|
||||
if (type && searchType && ['track', 'album', 'playlist', 'artist'].includes(type)) {
|
||||
searchType.value = type;
|
||||
}
|
||||
performSearch();
|
||||
@@ -74,12 +75,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
* Performs the search based on input values
|
||||
*/
|
||||
async function performSearch() {
|
||||
const query = searchInput.value.trim();
|
||||
if (!query) return;
|
||||
const currentQuery = searchInput?.value.trim();
|
||||
if (!currentQuery) return;
|
||||
|
||||
// Handle direct Spotify URLs
|
||||
if (isSpotifyUrl(query)) {
|
||||
const details = getSpotifyResourceDetails(query);
|
||||
if (isSpotifyUrl(currentQuery)) {
|
||||
const details = getSpotifyResourceDetails(currentQuery);
|
||||
if (details && details.id) {
|
||||
// Redirect to the appropriate page
|
||||
window.location.href = `/${details.type}/${details.id}`;
|
||||
@@ -88,16 +89,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
// Update URL without reloading page
|
||||
const newUrl = `${window.location.pathname}?q=${encodeURIComponent(query)}&type=${searchType.value}`;
|
||||
const currentSearchType = searchType?.value || 'track';
|
||||
const newUrl = `${window.location.pathname}?q=${encodeURIComponent(currentQuery)}&type=${currentSearchType}`;
|
||||
window.history.pushState({ path: newUrl }, '', newUrl);
|
||||
|
||||
// Show loading state
|
||||
showEmptyState(false);
|
||||
showLoading(true);
|
||||
resultsContainer.innerHTML = '';
|
||||
if(resultsContainer) resultsContainer.innerHTML = '';
|
||||
|
||||
try {
|
||||
const url = `/api/search?q=${encodeURIComponent(query)}&search_type=${searchType.value}&limit=40`;
|
||||
const url = `/api/search?q=${encodeURIComponent(currentQuery)}&search_type=${currentSearchType}&limit=40`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -111,47 +113,47 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Render results
|
||||
if (data && data.items && data.items.length > 0) {
|
||||
resultsContainer.innerHTML = '';
|
||||
if(resultsContainer) resultsContainer.innerHTML = '';
|
||||
|
||||
// Filter out items with null/undefined essential display parameters
|
||||
const validItems = filterValidItems(data.items, searchType.value);
|
||||
const validItems = filterValidItems(data.items, currentSearchType);
|
||||
|
||||
if (validItems.length === 0) {
|
||||
// No valid items found after filtering
|
||||
resultsContainer.innerHTML = `
|
||||
if(resultsContainer) resultsContainer.innerHTML = `
|
||||
<div class="empty-search-results">
|
||||
<p>No valid results found for "${query}"</p>
|
||||
<p>No valid results found for "${currentQuery}"</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
validItems.forEach((item, index) => {
|
||||
const cardElement = createResultCard(item, searchType.value, index);
|
||||
const cardElement = createResultCard(item, currentSearchType, index);
|
||||
|
||||
// Store the item data directly on the button element
|
||||
const downloadBtn = cardElement.querySelector('.download-btn');
|
||||
const downloadBtn = cardElement.querySelector('.download-btn') as HTMLButtonElement | null;
|
||||
if (downloadBtn) {
|
||||
downloadBtn.dataset.itemIndex = index;
|
||||
downloadBtn.dataset.itemIndex = index.toString();
|
||||
}
|
||||
|
||||
resultsContainer.appendChild(cardElement);
|
||||
if(resultsContainer) resultsContainer.appendChild(cardElement);
|
||||
});
|
||||
|
||||
// Attach download handlers to the newly created cards
|
||||
attachDownloadListeners(validItems);
|
||||
} else {
|
||||
// No results found
|
||||
resultsContainer.innerHTML = `
|
||||
if(resultsContainer) resultsContainer.innerHTML = `
|
||||
<div class="empty-search-results">
|
||||
<p>No results found for "${query}"</p>
|
||||
<p>No results found for "${currentQuery}"</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Error:', error);
|
||||
showLoading(false);
|
||||
resultsContainer.innerHTML = `
|
||||
if(resultsContainer) resultsContainer.innerHTML = `
|
||||
<div class="error">
|
||||
<p>Error searching: ${error.message}</p>
|
||||
</div>
|
||||
@@ -162,7 +164,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
/**
|
||||
* Filters out items with null/undefined essential display parameters based on search type
|
||||
*/
|
||||
function filterValidItems(items, type) {
|
||||
function filterValidItems(items: any[], type: string) {
|
||||
if (!items) return [];
|
||||
|
||||
return items.filter(item => {
|
||||
@@ -231,19 +233,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
/**
|
||||
* Attaches download handlers to result cards
|
||||
*/
|
||||
function attachDownloadListeners(items) {
|
||||
document.querySelectorAll('.download-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
function attachDownloadListeners(items: any[]) {
|
||||
document.querySelectorAll('.download-btn').forEach((btnElm) => {
|
||||
const btn = btnElm as HTMLButtonElement;
|
||||
btn.addEventListener('click', (e: Event) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Get the item index from the button's dataset
|
||||
const itemIndex = parseInt(btn.dataset.itemIndex, 10);
|
||||
const itemIndexStr = btn.dataset.itemIndex;
|
||||
if (!itemIndexStr) return;
|
||||
const itemIndex = parseInt(itemIndexStr, 10);
|
||||
|
||||
// Get the corresponding item
|
||||
const item = items[itemIndex];
|
||||
if (!item) return;
|
||||
|
||||
const type = searchType.value;
|
||||
const currentSearchType = searchType?.value || 'track';
|
||||
let url;
|
||||
|
||||
// Determine the URL based on item type
|
||||
@@ -266,17 +271,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
btn.disabled = true;
|
||||
|
||||
// For artist downloads, show a different message since it will queue multiple albums
|
||||
if (type === 'artist') {
|
||||
if (currentSearchType === 'artist') {
|
||||
btn.innerHTML = 'Queueing albums...';
|
||||
} else {
|
||||
btn.innerHTML = 'Queueing...';
|
||||
}
|
||||
|
||||
// Start the download
|
||||
startDownload(url, type, metadata, item.album ? item.album.album_type : null)
|
||||
startDownload(url, currentSearchType, metadata, item.album ? item.album.album_type : null)
|
||||
.then(() => {
|
||||
// For artists, show how many albums were queued
|
||||
if (type === 'artist') {
|
||||
if (currentSearchType === 'artist') {
|
||||
btn.innerHTML = 'Albums queued!';
|
||||
// Open the queue automatically for artist downloads
|
||||
downloadQueue.toggleVisibility(true);
|
||||
@@ -284,7 +289,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
btn.innerHTML = 'Queued!';
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch((error: any) => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = 'Download';
|
||||
showError('Failed to queue download: ' + error.message);
|
||||
@@ -296,7 +301,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
/**
|
||||
* Starts the download process via API
|
||||
*/
|
||||
async function startDownload(url, type, item, albumType) {
|
||||
async function startDownload(url: string, type: string, item: any, albumType: string | null) {
|
||||
if (!url || !type) {
|
||||
showError('Missing URL or type for download');
|
||||
return;
|
||||
@@ -308,7 +313,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
showError('Download failed: ' + (error.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
@@ -317,7 +322,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
/**
|
||||
* Shows an error message
|
||||
*/
|
||||
function showError(message) {
|
||||
function showError(message: string) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'error';
|
||||
errorDiv.textContent = message;
|
||||
@@ -330,7 +335,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
/**
|
||||
* Shows a success message
|
||||
*/
|
||||
function showSuccess(message) {
|
||||
function showSuccess(message: string) {
|
||||
const successDiv = document.createElement('div');
|
||||
successDiv.className = 'success';
|
||||
successDiv.textContent = message;
|
||||
@@ -343,7 +348,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
/**
|
||||
* Checks if a string is a valid Spotify URL
|
||||
*/
|
||||
function isSpotifyUrl(url) {
|
||||
function isSpotifyUrl(url: string): boolean {
|
||||
return url.includes('open.spotify.com') ||
|
||||
url.includes('spotify:') ||
|
||||
url.includes('link.tospotify.com');
|
||||
@@ -352,7 +357,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
/**
|
||||
* Extracts details from a Spotify URL
|
||||
*/
|
||||
function getSpotifyResourceDetails(url) {
|
||||
function getSpotifyResourceDetails(url: string): { type: string; id: string } | null {
|
||||
// Allow optional path segments (e.g. intl-fr) before resource type
|
||||
const regex = /spotify\.com\/(?:[^\/]+\/)??(track|album|playlist|artist)\/([a-zA-Z0-9]+)/i;
|
||||
const match = url.match(regex);
|
||||
@@ -369,7 +374,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
/**
|
||||
* Formats milliseconds to MM:SS
|
||||
*/
|
||||
function msToMinutesSeconds(ms) {
|
||||
function msToMinutesSeconds(ms: number | undefined): string {
|
||||
if (!ms) return '0:00';
|
||||
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
@@ -380,7 +385,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
/**
|
||||
* Creates a result card element
|
||||
*/
|
||||
function createResultCard(item, type, index) {
|
||||
function createResultCard(item: any, type: string, index: number): HTMLDivElement {
|
||||
const cardElement = document.createElement('div');
|
||||
cardElement.className = 'result-card';
|
||||
|
||||
@@ -433,10 +438,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
`;
|
||||
|
||||
// Add click event to navigate to the item's detail page
|
||||
cardElement.addEventListener('click', (e) => {
|
||||
cardElement.addEventListener('click', (e: MouseEvent) => {
|
||||
// Don't trigger if the download button was clicked
|
||||
if (e.target.classList.contains('download-btn') ||
|
||||
e.target.parentElement.classList.contains('download-btn')) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('download-btn') ||
|
||||
target.parentElement?.classList.contains('download-btn')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -451,7 +457,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
/**
|
||||
* Show/hide the empty state
|
||||
*/
|
||||
function showEmptyState(show) {
|
||||
function showEmptyState(show: boolean) {
|
||||
if (emptyState) {
|
||||
emptyState.style.display = show ? 'flex' : 'none';
|
||||
}
|
||||
@@ -460,7 +466,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
/**
|
||||
* Show/hide the loading indicator
|
||||
*/
|
||||
function showLoading(show) {
|
||||
function showLoading(show: boolean) {
|
||||
if (loadingResults) {
|
||||
loadingResults.classList.toggle('hidden', !show);
|
||||
}
|
||||
@@ -34,25 +34,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
/**
|
||||
* Renders playlist header and tracks.
|
||||
*/
|
||||
function renderPlaylist(playlist) {
|
||||
function renderPlaylist(playlist: any) {
|
||||
// Hide loading and error messages
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
document.getElementById('error').classList.add('hidden');
|
||||
const loadingEl = document.getElementById('loading');
|
||||
if (loadingEl) loadingEl.classList.add('hidden');
|
||||
const errorEl = document.getElementById('error');
|
||||
if (errorEl) errorEl.classList.add('hidden');
|
||||
|
||||
// Check if explicit filter is enabled
|
||||
const isExplicitFilterEnabled = downloadQueue.isExplicitFilterEnabled();
|
||||
|
||||
// Update header info
|
||||
document.getElementById('playlist-name').textContent = playlist.name || 'Unknown Playlist';
|
||||
document.getElementById('playlist-owner').textContent = `By ${playlist.owner?.display_name || 'Unknown User'}`;
|
||||
document.getElementById('playlist-stats').textContent =
|
||||
const playlistNameEl = document.getElementById('playlist-name');
|
||||
if (playlistNameEl) playlistNameEl.textContent = playlist.name || 'Unknown Playlist';
|
||||
const playlistOwnerEl = document.getElementById('playlist-owner');
|
||||
if (playlistOwnerEl) playlistOwnerEl.textContent = `By ${playlist.owner?.display_name || 'Unknown User'}`;
|
||||
const playlistStatsEl = document.getElementById('playlist-stats');
|
||||
if (playlistStatsEl) playlistStatsEl.textContent =
|
||||
`${playlist.followers?.total || '0'} followers • ${playlist.tracks?.total || '0'} songs`;
|
||||
document.getElementById('playlist-description').textContent = playlist.description || '';
|
||||
const playlistDescriptionEl = document.getElementById('playlist-description');
|
||||
if (playlistDescriptionEl) playlistDescriptionEl.textContent = playlist.description || '';
|
||||
const image = playlist.images?.[0]?.url || '/static/images/placeholder.jpg';
|
||||
document.getElementById('playlist-image').src = image;
|
||||
const playlistImageEl = document.getElementById('playlist-image') as HTMLImageElement;
|
||||
if (playlistImageEl) playlistImageEl.src = image;
|
||||
|
||||
// --- Add Home Button ---
|
||||
let homeButton = document.getElementById('homeButton');
|
||||
let homeButton = document.getElementById('homeButton') as HTMLButtonElement;
|
||||
if (!homeButton) {
|
||||
homeButton = document.createElement('button');
|
||||
homeButton.id = 'homeButton';
|
||||
@@ -77,7 +84,7 @@ function renderPlaylist(playlist) {
|
||||
}
|
||||
|
||||
// --- Add "Download Whole Playlist" Button ---
|
||||
let downloadPlaylistBtn = document.getElementById('downloadPlaylistBtn');
|
||||
let downloadPlaylistBtn = document.getElementById('downloadPlaylistBtn') as HTMLButtonElement;
|
||||
if (!downloadPlaylistBtn) {
|
||||
downloadPlaylistBtn = document.createElement('button');
|
||||
downloadPlaylistBtn.id = 'downloadPlaylistBtn';
|
||||
@@ -91,7 +98,7 @@ function renderPlaylist(playlist) {
|
||||
}
|
||||
|
||||
// --- Add "Download Playlist's Albums" Button ---
|
||||
let downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn');
|
||||
let downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn') as HTMLButtonElement;
|
||||
if (!downloadAlbumsBtn) {
|
||||
downloadAlbumsBtn = document.createElement('button');
|
||||
downloadAlbumsBtn.id = 'downloadAlbumsBtn';
|
||||
@@ -106,54 +113,62 @@ function renderPlaylist(playlist) {
|
||||
|
||||
if (isExplicitFilterEnabled && hasExplicitTrack) {
|
||||
// Disable both playlist buttons and display messages explaining why
|
||||
downloadPlaylistBtn.disabled = true;
|
||||
downloadPlaylistBtn.classList.add('download-btn--disabled');
|
||||
downloadPlaylistBtn.innerHTML = `<span title="Cannot download entire playlist because it contains explicit tracks">Playlist Contains Explicit Tracks</span>`;
|
||||
if (downloadPlaylistBtn) {
|
||||
downloadPlaylistBtn.disabled = true;
|
||||
downloadPlaylistBtn.classList.add('download-btn--disabled');
|
||||
downloadPlaylistBtn.innerHTML = `<span title="Cannot download entire playlist because it contains explicit tracks">Playlist Contains Explicit Tracks</span>`;
|
||||
}
|
||||
|
||||
downloadAlbumsBtn.disabled = true;
|
||||
downloadAlbumsBtn.classList.add('download-btn--disabled');
|
||||
downloadAlbumsBtn.innerHTML = `<span title="Cannot download albums from this playlist because it contains explicit tracks">Albums Access Restricted</span>`;
|
||||
if (downloadAlbumsBtn) {
|
||||
downloadAlbumsBtn.disabled = true;
|
||||
downloadAlbumsBtn.classList.add('download-btn--disabled');
|
||||
downloadAlbumsBtn.innerHTML = `<span title="Cannot download albums from this playlist because it contains explicit tracks">Albums Access Restricted</span>`;
|
||||
}
|
||||
} else {
|
||||
// Normal behavior when no explicit tracks are present
|
||||
downloadPlaylistBtn.addEventListener('click', () => {
|
||||
// Remove individual track download buttons (but leave the whole playlist button).
|
||||
document.querySelectorAll('.download-btn').forEach(btn => {
|
||||
if (btn.id !== 'downloadPlaylistBtn') {
|
||||
btn.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Disable the whole playlist button to prevent repeated clicks.
|
||||
downloadPlaylistBtn.disabled = true;
|
||||
downloadPlaylistBtn.textContent = 'Queueing...';
|
||||
|
||||
// Initiate the playlist download.
|
||||
downloadWholePlaylist(playlist).then(() => {
|
||||
downloadPlaylistBtn.textContent = 'Queued!';
|
||||
}).catch(err => {
|
||||
showError('Failed to queue playlist download: ' + (err?.message || 'Unknown error'));
|
||||
downloadPlaylistBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
downloadAlbumsBtn.addEventListener('click', () => {
|
||||
// Remove individual track download buttons (but leave this album button).
|
||||
document.querySelectorAll('.download-btn').forEach(btn => {
|
||||
if (btn.id !== 'downloadAlbumsBtn') btn.remove();
|
||||
});
|
||||
|
||||
downloadAlbumsBtn.disabled = true;
|
||||
downloadAlbumsBtn.textContent = 'Queueing...';
|
||||
|
||||
downloadPlaylistAlbums(playlist)
|
||||
.then(() => {
|
||||
downloadAlbumsBtn.textContent = 'Queued!';
|
||||
})
|
||||
.catch(err => {
|
||||
showError('Failed to queue album downloads: ' + (err?.message || 'Unknown error'));
|
||||
downloadAlbumsBtn.disabled = false;
|
||||
if (downloadPlaylistBtn) {
|
||||
downloadPlaylistBtn.addEventListener('click', () => {
|
||||
// Remove individual track download buttons (but leave the whole playlist button).
|
||||
document.querySelectorAll('.download-btn').forEach(btn => {
|
||||
if (btn.id !== 'downloadPlaylistBtn') {
|
||||
btn.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Disable the whole playlist button to prevent repeated clicks.
|
||||
downloadPlaylistBtn.disabled = true;
|
||||
downloadPlaylistBtn.textContent = 'Queueing...';
|
||||
|
||||
// Initiate the playlist download.
|
||||
downloadWholePlaylist(playlist).then(() => {
|
||||
downloadPlaylistBtn.textContent = 'Queued!';
|
||||
}).catch((err: any) => {
|
||||
showError('Failed to queue playlist download: ' + (err?.message || 'Unknown error'));
|
||||
downloadPlaylistBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (downloadAlbumsBtn) {
|
||||
downloadAlbumsBtn.addEventListener('click', () => {
|
||||
// Remove individual track download buttons (but leave this album button).
|
||||
document.querySelectorAll('.download-btn').forEach(btn => {
|
||||
if (btn.id !== 'downloadAlbumsBtn') btn.remove();
|
||||
});
|
||||
|
||||
downloadAlbumsBtn.disabled = true;
|
||||
downloadAlbumsBtn.textContent = 'Queueing...';
|
||||
|
||||
downloadPlaylistAlbums(playlist)
|
||||
.then(() => {
|
||||
if (downloadAlbumsBtn) downloadAlbumsBtn.textContent = 'Queued!';
|
||||
})
|
||||
.catch((err: any) => {
|
||||
showError('Failed to queue album downloads: ' + (err?.message || 'Unknown error'));
|
||||
if (downloadAlbumsBtn) downloadAlbumsBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Render tracks list
|
||||
@@ -220,8 +235,10 @@ function renderPlaylist(playlist) {
|
||||
}
|
||||
|
||||
// Reveal header and tracks container
|
||||
document.getElementById('playlist-header').classList.remove('hidden');
|
||||
document.getElementById('tracks-container').classList.remove('hidden');
|
||||
const playlistHeaderEl = document.getElementById('playlist-header');
|
||||
if (playlistHeaderEl) playlistHeaderEl.classList.remove('hidden');
|
||||
const tracksContainerEl = document.getElementById('tracks-container');
|
||||
if (tracksContainerEl) tracksContainerEl.classList.remove('hidden');
|
||||
|
||||
// Attach download listeners to newly rendered download buttons
|
||||
attachDownloadListeners();
|
||||
@@ -230,7 +247,7 @@ function renderPlaylist(playlist) {
|
||||
/**
|
||||
* Converts milliseconds to minutes:seconds.
|
||||
*/
|
||||
function msToTime(duration) {
|
||||
function msToTime(duration: number) {
|
||||
if (!duration || isNaN(duration)) return '0:00';
|
||||
|
||||
const minutes = Math.floor(duration / 60000);
|
||||
@@ -241,7 +258,7 @@ function msToTime(duration) {
|
||||
/**
|
||||
* Displays an error message in the UI.
|
||||
*/
|
||||
function showError(message) {
|
||||
function showError(message: string) {
|
||||
const errorEl = document.getElementById('error');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message || 'An error occurred';
|
||||
@@ -256,14 +273,15 @@ function attachDownloadListeners() {
|
||||
document.querySelectorAll('.download-btn').forEach((btn) => {
|
||||
// Skip the whole playlist and album download buttons.
|
||||
if (btn.id === 'downloadPlaylistBtn' || btn.id === 'downloadAlbumsBtn') return;
|
||||
btn.addEventListener('click', (e) => {
|
||||
btn.addEventListener('click', (e: Event) => {
|
||||
e.stopPropagation();
|
||||
const url = e.currentTarget.dataset.url || '';
|
||||
const type = e.currentTarget.dataset.type || '';
|
||||
const name = e.currentTarget.dataset.name || extractName(url) || 'Unknown';
|
||||
const currentTarget = e.currentTarget as HTMLButtonElement;
|
||||
const url = currentTarget.dataset.url || '';
|
||||
const type = currentTarget.dataset.type || '';
|
||||
const name = currentTarget.dataset.name || extractName(url) || 'Unknown';
|
||||
// Remove the button immediately after click.
|
||||
e.currentTarget.remove();
|
||||
startDownload(url, type, { name });
|
||||
currentTarget.remove();
|
||||
startDownload(url, type, { name }, ''); // Added empty string for albumType
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -271,7 +289,7 @@ function attachDownloadListeners() {
|
||||
/**
|
||||
* Initiates the whole playlist download by calling the playlist endpoint.
|
||||
*/
|
||||
async function downloadWholePlaylist(playlist) {
|
||||
async function downloadWholePlaylist(playlist: any) {
|
||||
if (!playlist) {
|
||||
throw new Error('Invalid playlist data');
|
||||
}
|
||||
@@ -286,7 +304,7 @@ async function downloadWholePlaylist(playlist) {
|
||||
await downloadQueue.download(url, 'playlist', { name: playlist.name || 'Unknown Playlist' });
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
showError('Playlist download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
@@ -297,7 +315,7 @@ async function downloadWholePlaylist(playlist) {
|
||||
* adding a 20ms delay between each album download and updating the button
|
||||
* with the progress (queued_albums/total_albums).
|
||||
*/
|
||||
async function downloadPlaylistAlbums(playlist) {
|
||||
async function downloadPlaylistAlbums(playlist: any) {
|
||||
if (!playlist?.tracks?.items) {
|
||||
showError('No tracks found in this playlist.');
|
||||
return;
|
||||
@@ -322,7 +340,7 @@ async function downloadPlaylistAlbums(playlist) {
|
||||
}
|
||||
|
||||
// Get a reference to the "Download Playlist's Albums" button.
|
||||
const downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn');
|
||||
const downloadAlbumsBtn = document.getElementById('downloadAlbumsBtn') as HTMLButtonElement | null;
|
||||
if (downloadAlbumsBtn) {
|
||||
// Initialize the progress display.
|
||||
downloadAlbumsBtn.textContent = `0/${totalAlbums}`;
|
||||
@@ -360,7 +378,7 @@ async function downloadPlaylistAlbums(playlist) {
|
||||
|
||||
// Make the queue visible after queueing all albums
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
// Propagate any errors encountered.
|
||||
throw error;
|
||||
}
|
||||
@@ -369,7 +387,7 @@ async function downloadPlaylistAlbums(playlist) {
|
||||
/**
|
||||
* Starts the download process using the centralized download method from the queue.
|
||||
*/
|
||||
async function startDownload(url, type, item, albumType) {
|
||||
async function startDownload(url: string, type: string, item: any, albumType?: string) {
|
||||
if (!url || !type) {
|
||||
showError('Missing URL or type for download');
|
||||
return;
|
||||
@@ -381,7 +399,7 @@ async function startDownload(url, type, item, albumType) {
|
||||
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
@@ -390,6 +408,6 @@ async function startDownload(url, type, item, albumType) {
|
||||
/**
|
||||
* A helper function to extract a display name from the URL.
|
||||
*/
|
||||
function extractName(url) {
|
||||
function extractName(url: string | null): string {
|
||||
return url || 'Unknown';
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,15 +35,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
/**
|
||||
* Renders the track header information.
|
||||
*/
|
||||
function renderTrack(track) {
|
||||
function renderTrack(track: any) {
|
||||
// Hide the loading and error messages.
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
document.getElementById('error').classList.add('hidden');
|
||||
const loadingEl = document.getElementById('loading');
|
||||
if (loadingEl) loadingEl.classList.add('hidden');
|
||||
const errorEl = document.getElementById('error');
|
||||
if (errorEl) errorEl.classList.add('hidden');
|
||||
|
||||
// Check if track is explicit and if explicit filter is enabled
|
||||
if (track.explicit && downloadQueue.isExplicitFilterEnabled()) {
|
||||
// Show placeholder for explicit content
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
const loadingElExplicit = document.getElementById('loading');
|
||||
if (loadingElExplicit) loadingElExplicit.classList.add('hidden');
|
||||
|
||||
const placeholderContent = `
|
||||
<div class="explicit-filter-placeholder">
|
||||
@@ -63,30 +66,46 @@ function renderTrack(track) {
|
||||
}
|
||||
|
||||
// Update track information fields.
|
||||
document.getElementById('track-name').innerHTML =
|
||||
`<a href="/track/${track.id || ''}" title="View track details">${track.name || 'Unknown Track'}</a>`;
|
||||
const trackNameEl = document.getElementById('track-name');
|
||||
if (trackNameEl) {
|
||||
trackNameEl.innerHTML =
|
||||
`<a href="/track/${track.id || ''}" title="View track details">${track.name || 'Unknown Track'}</a>`;
|
||||
}
|
||||
|
||||
document.getElementById('track-artist').innerHTML =
|
||||
`By ${track.artists?.map(a =>
|
||||
`<a href="/artist/${a?.id || ''}" title="View artist details">${a?.name || 'Unknown Artist'}</a>`
|
||||
).join(', ') || 'Unknown Artist'}`;
|
||||
const trackArtistEl = document.getElementById('track-artist');
|
||||
if (trackArtistEl) {
|
||||
trackArtistEl.innerHTML =
|
||||
`By ${track.artists?.map((a: any) =>
|
||||
`<a href="/artist/${a?.id || ''}" title="View artist details">${a?.name || 'Unknown Artist'}</a>`
|
||||
).join(', ') || 'Unknown Artist'}`;
|
||||
}
|
||||
|
||||
document.getElementById('track-album').innerHTML =
|
||||
`Album: <a href="/album/${track.album?.id || ''}" title="View album details">${track.album?.name || 'Unknown Album'}</a> (${track.album?.album_type || 'album'})`;
|
||||
const trackAlbumEl = document.getElementById('track-album');
|
||||
if (trackAlbumEl) {
|
||||
trackAlbumEl.innerHTML =
|
||||
`Album: <a href="/album/${track.album?.id || ''}" title="View album details">${track.album?.name || 'Unknown Album'}</a> (${track.album?.album_type || 'album'})`;
|
||||
}
|
||||
|
||||
document.getElementById('track-duration').textContent =
|
||||
`Duration: ${msToTime(track.duration_ms || 0)}`;
|
||||
const trackDurationEl = document.getElementById('track-duration');
|
||||
if (trackDurationEl) {
|
||||
trackDurationEl.textContent =
|
||||
`Duration: ${msToTime(track.duration_ms || 0)}`;
|
||||
}
|
||||
|
||||
document.getElementById('track-explicit').textContent =
|
||||
track.explicit ? 'Explicit' : 'Clean';
|
||||
const trackExplicitEl = document.getElementById('track-explicit');
|
||||
if (trackExplicitEl) {
|
||||
trackExplicitEl.textContent =
|
||||
track.explicit ? 'Explicit' : 'Clean';
|
||||
}
|
||||
|
||||
const imageUrl = (track.album?.images && track.album.images[0])
|
||||
? track.album.images[0].url
|
||||
: '/static/images/placeholder.jpg';
|
||||
document.getElementById('track-album-image').src = imageUrl;
|
||||
const trackAlbumImageEl = document.getElementById('track-album-image') as HTMLImageElement;
|
||||
if (trackAlbumImageEl) trackAlbumImageEl.src = imageUrl;
|
||||
|
||||
// --- Insert Home Button (if not already present) ---
|
||||
let homeButton = document.getElementById('homeButton');
|
||||
let homeButton = document.getElementById('homeButton') as HTMLButtonElement;
|
||||
if (!homeButton) {
|
||||
homeButton = document.createElement('button');
|
||||
homeButton.id = 'homeButton';
|
||||
@@ -103,7 +122,7 @@ function renderTrack(track) {
|
||||
});
|
||||
|
||||
// --- Move the Download Button from #actions into #track-header ---
|
||||
let downloadBtn = document.getElementById('downloadTrackBtn');
|
||||
let downloadBtn = document.getElementById('downloadTrackBtn') as HTMLButtonElement;
|
||||
if (downloadBtn) {
|
||||
// Remove the parent container (#actions) if needed.
|
||||
const actionsContainer = document.getElementById('actions');
|
||||
@@ -139,7 +158,7 @@ function renderTrack(track) {
|
||||
// Make the queue visible to show the download
|
||||
downloadQueue.toggleVisibility(true);
|
||||
})
|
||||
.catch(err => {
|
||||
.catch((err: any) => {
|
||||
showError('Failed to queue track download: ' + (err?.message || 'Unknown error'));
|
||||
downloadBtn.disabled = false;
|
||||
downloadBtn.innerHTML = `<img src="/static/images/download.svg" alt="Download">`;
|
||||
@@ -148,13 +167,14 @@ function renderTrack(track) {
|
||||
}
|
||||
|
||||
// Reveal the header now that track info is loaded.
|
||||
document.getElementById('track-header').classList.remove('hidden');
|
||||
const trackHeaderEl = document.getElementById('track-header');
|
||||
if (trackHeaderEl) trackHeaderEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts milliseconds to minutes:seconds.
|
||||
*/
|
||||
function msToTime(duration) {
|
||||
function msToTime(duration: number) {
|
||||
if (!duration || isNaN(duration)) return '0:00';
|
||||
|
||||
const minutes = Math.floor(duration / 60000);
|
||||
@@ -165,7 +185,7 @@ function msToTime(duration) {
|
||||
/**
|
||||
* Displays an error message in the UI.
|
||||
*/
|
||||
function showError(message) {
|
||||
function showError(message: string) {
|
||||
const errorEl = document.getElementById('error');
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message || 'An error occurred';
|
||||
@@ -176,7 +196,7 @@ function showError(message) {
|
||||
/**
|
||||
* Starts the download process by calling the centralized downloadQueue method
|
||||
*/
|
||||
async function startDownload(url, type, item) {
|
||||
async function startDownload(url: string, type: string, item: any) {
|
||||
if (!url || !type) {
|
||||
showError('Missing URL or type for download');
|
||||
return;
|
||||
@@ -188,7 +208,7 @@ async function startDownload(url, type, item) {
|
||||
|
||||
// Make the queue visible after queueing
|
||||
downloadQueue.toggleVisibility(true);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
showError('Download failed: ' + (error?.message || 'Unknown error'));
|
||||
throw error;
|
||||
}
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017", // Specify ECMAScript target version
|
||||
"module": "ES2020", // Specify module code generation
|
||||
"strict": true, // Enable all strict type-checking options
|
||||
"noImplicitAny": false, // Allow implicit 'any' types
|
||||
"esModuleInterop": true, // Enables emit interoperability between CommonJS and ES Modules
|
||||
"skipLibCheck": true, // Skip type checking of declaration files
|
||||
"forceConsistentCasingInFileNames": true // Disallow inconsistently-cased references to the same file.
|
||||
},
|
||||
"include": [
|
||||
"static/js/**/*.ts" // Specifies the TypeScript files to be included in compilation
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules" // Specifies an array of filenames or patterns that should be skipped when resolving include.
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user