627 lines
24 KiB
TypeScript
627 lines
24 KiB
TypeScript
// main.ts
|
|
import { downloadQueue } from './queue.js';
|
|
|
|
// Define interfaces for API data and search results
|
|
interface Image {
|
|
url: string;
|
|
height?: number;
|
|
width?: number;
|
|
}
|
|
|
|
interface Artist {
|
|
id?: string; // Artist ID might not always be present in search results for track artists
|
|
name: string;
|
|
external_urls?: { spotify?: string };
|
|
genres?: string[]; // For artist type results
|
|
}
|
|
|
|
interface Album {
|
|
id?: string; // Album ID might not always be present
|
|
name: string;
|
|
images?: Image[];
|
|
album_type?: string; // Used in startDownload
|
|
artists?: Artist[]; // Album can have artists too
|
|
total_tracks?: number;
|
|
release_date?: string;
|
|
external_urls?: { spotify?: string };
|
|
}
|
|
|
|
interface Track {
|
|
id: string;
|
|
name: string;
|
|
artists: Artist[];
|
|
album: Album;
|
|
duration_ms?: number;
|
|
explicit?: boolean;
|
|
external_urls: { spotify: string };
|
|
href?: string; // Some spotify responses use href
|
|
}
|
|
|
|
interface Playlist {
|
|
id: string;
|
|
name: string;
|
|
owner: { display_name?: string; id?: string };
|
|
images?: Image[];
|
|
tracks: { total: number }; // Simplified for search results
|
|
external_urls: { spotify: string };
|
|
href?: string; // Some spotify responses use href
|
|
explicit?: boolean; // Playlists themselves aren't explicit, but items can be
|
|
}
|
|
|
|
// Specific item types for search results
|
|
interface TrackResultItem extends Track {}
|
|
interface AlbumResultItem extends Album { id: string; images?: Image[]; explicit?: boolean; external_urls: { spotify: string }; href?: string; }
|
|
interface PlaylistResultItem extends Playlist {}
|
|
interface ArtistResultItem extends Artist { id: string; images?: Image[]; explicit?: boolean; external_urls: { spotify: string }; href?: string; followers?: { total: number }; }
|
|
|
|
// Union type for any search result item
|
|
type SearchResultItem = TrackResultItem | AlbumResultItem | PlaylistResultItem | ArtistResultItem;
|
|
|
|
// Interface for the API response structure
|
|
interface SearchResponse {
|
|
items: SearchResultItem[];
|
|
// Add other top-level properties from the search API if needed (e.g., total, limit, offset)
|
|
}
|
|
|
|
// Interface for the item passed to downloadQueue.download
|
|
interface DownloadQueueItem {
|
|
name: string;
|
|
artist?: string;
|
|
album?: { name: string; album_type?: string };
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// DOM elements
|
|
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');
|
|
const loadingResults = document.getElementById('loadingResults');
|
|
const watchlistButton = document.getElementById('watchlistButton') as HTMLAnchorElement | null;
|
|
|
|
// Initialize the queue
|
|
if (queueIcon) {
|
|
queueIcon.addEventListener('click', () => {
|
|
downloadQueue.toggleVisibility();
|
|
});
|
|
}
|
|
|
|
// Add event listeners
|
|
if (searchButton) {
|
|
searchButton.addEventListener('click', performSearch);
|
|
}
|
|
|
|
if (searchInput) {
|
|
searchInput.addEventListener('keypress', function(e: KeyboardEvent) {
|
|
if (e.key === 'Enter') {
|
|
performSearch();
|
|
}
|
|
});
|
|
|
|
// Auto-detect and handle pasted Spotify URLs
|
|
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 && searchType) {
|
|
searchType.value = details.type;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Restore last search type if no URL override
|
|
const savedType = localStorage.getItem('lastSearchType');
|
|
if (searchType && savedType && ['track','album','playlist','artist'].includes(savedType)) {
|
|
searchType.value = savedType;
|
|
}
|
|
// Save last selection on change
|
|
if (searchType) {
|
|
searchType.addEventListener('change', () => {
|
|
localStorage.setItem('lastSearchType', searchType.value);
|
|
});
|
|
}
|
|
|
|
// Attempt to set initial watchlist button visibility from cache
|
|
if (watchlistButton) {
|
|
const cachedWatchEnabled = localStorage.getItem('spotizerr_watch_enabled_cached');
|
|
if (cachedWatchEnabled === 'true') {
|
|
watchlistButton.classList.remove('hidden');
|
|
}
|
|
}
|
|
|
|
// Fetch watch config to determine if watchlist button should be visible
|
|
async function updateWatchlistButtonVisibility() {
|
|
if (watchlistButton) {
|
|
try {
|
|
const response = await fetch('/api/config/watch');
|
|
if (response.ok) {
|
|
const watchConfig = await response.json();
|
|
localStorage.setItem('spotizerr_watch_enabled_cached', watchConfig.enabled ? 'true' : 'false');
|
|
if (watchConfig && watchConfig.enabled === false) {
|
|
watchlistButton.classList.add('hidden');
|
|
} else {
|
|
watchlistButton.classList.remove('hidden'); // Ensure it's shown if enabled
|
|
}
|
|
} else {
|
|
console.error('Failed to fetch watch config, defaulting to hidden');
|
|
// Don't update cache on error, rely on default hidden or previous cache state until success
|
|
watchlistButton.classList.add('hidden'); // Hide if config fetch fails
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching watch config:', error);
|
|
// Don't update cache on error
|
|
watchlistButton.classList.add('hidden'); // Hide on error
|
|
}
|
|
}
|
|
}
|
|
updateWatchlistButtonVisibility();
|
|
|
|
// Check for URL parameters
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const query = urlParams.get('q');
|
|
const type = urlParams.get('type');
|
|
|
|
if (query && searchInput) {
|
|
searchInput.value = query;
|
|
if (type && searchType && ['track', 'album', 'playlist', 'artist'].includes(type)) {
|
|
searchType.value = type;
|
|
}
|
|
performSearch();
|
|
} else {
|
|
// Show empty state if no query
|
|
showEmptyState(true);
|
|
}
|
|
|
|
/**
|
|
* Performs the search based on input values
|
|
*/
|
|
async function performSearch() {
|
|
const currentQuery = searchInput?.value.trim();
|
|
if (!currentQuery) return;
|
|
|
|
// Handle direct Spotify URLs
|
|
if (isSpotifyUrl(currentQuery)) {
|
|
const details = getSpotifyResourceDetails(currentQuery);
|
|
if (details && details.id) {
|
|
// Redirect to the appropriate page
|
|
window.location.href = `/${details.type}/${details.id}`;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Update URL without reloading page
|
|
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);
|
|
if(resultsContainer) resultsContainer.innerHTML = '';
|
|
|
|
try {
|
|
const url = `/api/search?q=${encodeURIComponent(currentQuery)}&search_type=${currentSearchType}&limit=40`;
|
|
const response = await fetch(url);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Network response was not ok');
|
|
}
|
|
|
|
const data = await response.json() as SearchResponse; // Assert type for API response
|
|
|
|
// Hide loading indicator
|
|
showLoading(false);
|
|
|
|
// Render results
|
|
if (data && data.items && data.items.length > 0) {
|
|
if(resultsContainer) resultsContainer.innerHTML = '';
|
|
|
|
// Filter out items with null/undefined essential display parameters
|
|
const validItems = filterValidItems(data.items, currentSearchType);
|
|
|
|
if (validItems.length === 0) {
|
|
// No valid items found after filtering
|
|
if(resultsContainer) resultsContainer.innerHTML = `
|
|
<div class="empty-search-results">
|
|
<p>No valid results found for "${currentQuery}"</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
validItems.forEach((item, index) => {
|
|
const cardElement = createResultCard(item, currentSearchType, index);
|
|
|
|
// Store the item data directly on the button element
|
|
const downloadBtn = cardElement.querySelector('.download-btn') as HTMLButtonElement | null;
|
|
if (downloadBtn) {
|
|
downloadBtn.dataset.itemIndex = index.toString();
|
|
}
|
|
|
|
if(resultsContainer) resultsContainer.appendChild(cardElement);
|
|
});
|
|
|
|
// Attach download handlers to the newly created cards
|
|
attachDownloadListeners(validItems);
|
|
} else {
|
|
// No results found
|
|
if(resultsContainer) resultsContainer.innerHTML = `
|
|
<div class="empty-search-results">
|
|
<p>No results found for "${currentQuery}"</p>
|
|
</div>
|
|
`;
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Error:', error);
|
|
showLoading(false);
|
|
if(resultsContainer) resultsContainer.innerHTML = `
|
|
<div class="error">
|
|
<p>Error searching: ${error.message}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filters out items with null/undefined essential display parameters based on search type
|
|
*/
|
|
function filterValidItems(items: SearchResultItem[], type: string): SearchResultItem[] {
|
|
if (!items) return [];
|
|
|
|
return items.filter(item => {
|
|
// Skip null/undefined items
|
|
if (!item) return false;
|
|
|
|
// Skip explicit content if filter is enabled
|
|
if (downloadQueue.isExplicitFilterEnabled() && ('explicit' in item && item.explicit === true)) {
|
|
return false;
|
|
}
|
|
|
|
// Check essential parameters based on search type
|
|
switch (type) {
|
|
case 'track':
|
|
const trackItem = item as TrackResultItem;
|
|
return (
|
|
trackItem.name &&
|
|
trackItem.artists &&
|
|
trackItem.artists.length > 0 &&
|
|
trackItem.artists[0] &&
|
|
trackItem.artists[0].name &&
|
|
trackItem.album &&
|
|
trackItem.album.name &&
|
|
trackItem.external_urls &&
|
|
trackItem.external_urls.spotify
|
|
);
|
|
|
|
case 'album':
|
|
const albumItem = item as AlbumResultItem;
|
|
return (
|
|
albumItem.name &&
|
|
albumItem.artists &&
|
|
albumItem.artists.length > 0 &&
|
|
albumItem.artists[0] &&
|
|
albumItem.artists[0].name &&
|
|
albumItem.external_urls &&
|
|
albumItem.external_urls.spotify
|
|
);
|
|
|
|
case 'playlist':
|
|
const playlistItem = item as PlaylistResultItem;
|
|
return (
|
|
playlistItem.name &&
|
|
playlistItem.owner &&
|
|
playlistItem.owner.display_name &&
|
|
playlistItem.tracks &&
|
|
playlistItem.external_urls &&
|
|
playlistItem.external_urls.spotify
|
|
);
|
|
|
|
case 'artist':
|
|
const artistItem = item as ArtistResultItem;
|
|
return (
|
|
artistItem.name &&
|
|
artistItem.external_urls &&
|
|
artistItem.external_urls.spotify
|
|
);
|
|
|
|
default:
|
|
// Default case - just check if the item exists (already handled by `if (!item) return false;`)
|
|
return true;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Attaches download handlers to result cards
|
|
*/
|
|
function attachDownloadListeners(items: SearchResultItem[]) {
|
|
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 itemIndexStr = btn.dataset.itemIndex;
|
|
if (!itemIndexStr) return;
|
|
const itemIndex = parseInt(itemIndexStr, 10);
|
|
|
|
// Get the corresponding item
|
|
const item = items[itemIndex];
|
|
if (!item) return;
|
|
|
|
const currentSearchType = searchType?.value || 'track';
|
|
let itemId = item.id || ''; // Use item.id directly
|
|
|
|
if (!itemId) { // Check if ID was found
|
|
showError('Could not determine download ID');
|
|
return;
|
|
}
|
|
|
|
// Prepare metadata for the download
|
|
let metadata: DownloadQueueItem;
|
|
if (currentSearchType === 'track') {
|
|
const trackItem = item as TrackResultItem;
|
|
metadata = {
|
|
name: trackItem.name || 'Unknown',
|
|
artist: trackItem.artists ? trackItem.artists[0]?.name : undefined,
|
|
album: trackItem.album ? { name: trackItem.album.name, album_type: trackItem.album.album_type } : undefined
|
|
};
|
|
} else if (currentSearchType === 'album') {
|
|
const albumItem = item as AlbumResultItem;
|
|
metadata = {
|
|
name: albumItem.name || 'Unknown',
|
|
artist: albumItem.artists ? albumItem.artists[0]?.name : undefined,
|
|
album: { name: albumItem.name, album_type: albumItem.album_type}
|
|
};
|
|
} else if (currentSearchType === 'playlist') {
|
|
const playlistItem = item as PlaylistResultItem;
|
|
metadata = {
|
|
name: playlistItem.name || 'Unknown',
|
|
// artist for playlist is owner
|
|
artist: playlistItem.owner?.display_name
|
|
};
|
|
} else if (currentSearchType === 'artist') {
|
|
const artistItem = item as ArtistResultItem;
|
|
metadata = {
|
|
name: artistItem.name || 'Unknown',
|
|
artist: artistItem.name // For artist type, artist is the item name itself
|
|
};
|
|
} else {
|
|
metadata = { name: item.name || 'Unknown' }; // Fallback
|
|
}
|
|
|
|
// Disable the button and update text
|
|
btn.disabled = true;
|
|
|
|
// For artist downloads, show a different message since it will queue multiple albums
|
|
if (currentSearchType === 'artist') {
|
|
btn.innerHTML = 'Queueing albums...';
|
|
} else {
|
|
btn.innerHTML = 'Queueing...';
|
|
}
|
|
|
|
// Start the download
|
|
startDownload(itemId, currentSearchType, metadata,
|
|
(item as AlbumResultItem).album_type || ((item as TrackResultItem).album ? (item as TrackResultItem).album.album_type : null))
|
|
.then(() => {
|
|
// For artists, show how many albums were queued
|
|
if (currentSearchType === 'artist') {
|
|
btn.innerHTML = 'Albums queued!';
|
|
// Open the queue automatically for artist downloads
|
|
downloadQueue.toggleVisibility(true);
|
|
} else {
|
|
btn.innerHTML = 'Queued!';
|
|
}
|
|
})
|
|
.catch((error: any) => {
|
|
btn.disabled = false;
|
|
btn.innerHTML = 'Download';
|
|
showError('Failed to queue download: ' + error.message);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Starts the download process via API
|
|
*/
|
|
async function startDownload(itemId: string, type: string, item: DownloadQueueItem, albumType: string | null | undefined) {
|
|
if (!itemId || !type) {
|
|
showError('Missing ID or type for download');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Use the centralized downloadQueue.download method
|
|
await downloadQueue.download(itemId, type, item, albumType);
|
|
|
|
// Make the queue visible after queueing
|
|
downloadQueue.toggleVisibility(true);
|
|
} catch (error: any) {
|
|
showError('Download failed: ' + (error.message || 'Unknown error'));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shows an error message
|
|
*/
|
|
function showError(message: string) {
|
|
const errorDiv = document.createElement('div');
|
|
errorDiv.className = 'error';
|
|
errorDiv.textContent = message;
|
|
document.body.appendChild(errorDiv);
|
|
|
|
// Auto-remove after 5 seconds
|
|
setTimeout(() => errorDiv.remove(), 5000);
|
|
}
|
|
|
|
/**
|
|
* Shows a success message
|
|
*/
|
|
function showSuccess(message: string) {
|
|
const successDiv = document.createElement('div');
|
|
successDiv.className = 'success';
|
|
successDiv.textContent = message;
|
|
document.body.appendChild(successDiv);
|
|
|
|
// Auto-remove after 5 seconds
|
|
setTimeout(() => successDiv.remove(), 5000);
|
|
}
|
|
|
|
/**
|
|
* Checks if a string is a valid Spotify URL
|
|
*/
|
|
function isSpotifyUrl(url: string): boolean {
|
|
return url.includes('open.spotify.com') ||
|
|
url.includes('spotify:') ||
|
|
url.includes('link.tospotify.com');
|
|
}
|
|
|
|
/**
|
|
* Extracts details from a Spotify 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);
|
|
|
|
if (match) {
|
|
return {
|
|
type: match[1],
|
|
id: match[2]
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Formats milliseconds to MM:SS
|
|
*/
|
|
function msToMinutesSeconds(ms: number | undefined): string {
|
|
if (!ms) return '0:00';
|
|
|
|
const minutes = Math.floor(ms / 60000);
|
|
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
|
return `${minutes}:${seconds.padStart(2, '0')}`;
|
|
}
|
|
|
|
/**
|
|
* Creates a result card element
|
|
*/
|
|
function createResultCard(item: SearchResultItem, type: string, index: number): HTMLDivElement {
|
|
const cardElement = document.createElement('div');
|
|
cardElement.className = 'result-card';
|
|
|
|
// Set cursor to pointer for clickable cards
|
|
cardElement.style.cursor = 'pointer';
|
|
|
|
// Get the appropriate image URL
|
|
let imageUrl = '/static/images/placeholder.jpg';
|
|
// Type guards to safely access images
|
|
if (type === 'album' || type === 'artist') {
|
|
const albumOrArtistItem = item as AlbumResultItem | ArtistResultItem;
|
|
if (albumOrArtistItem.images && albumOrArtistItem.images.length > 0) {
|
|
imageUrl = albumOrArtistItem.images[0].url;
|
|
}
|
|
} else if (type === 'track') {
|
|
const trackItem = item as TrackResultItem;
|
|
if (trackItem.album && trackItem.album.images && trackItem.album.images.length > 0) {
|
|
imageUrl = trackItem.album.images[0].url;
|
|
}
|
|
} else if (type === 'playlist') {
|
|
const playlistItem = item as PlaylistResultItem;
|
|
if (playlistItem.images && playlistItem.images.length > 0) {
|
|
imageUrl = playlistItem.images[0].url;
|
|
}
|
|
}
|
|
|
|
// Get the appropriate details based on type
|
|
let subtitle = '';
|
|
let details = '';
|
|
|
|
switch (type) {
|
|
case 'track':
|
|
{
|
|
const trackItem = item as TrackResultItem;
|
|
subtitle = trackItem.artists ? trackItem.artists.map((a: Artist) => a.name).join(', ') : 'Unknown Artist';
|
|
details = trackItem.album ? `<span>${trackItem.album.name}</span><span class="duration">${msToMinutesSeconds(trackItem.duration_ms)}</span>` : '';
|
|
}
|
|
break;
|
|
case 'album':
|
|
{
|
|
const albumItem = item as AlbumResultItem;
|
|
subtitle = albumItem.artists ? albumItem.artists.map((a: Artist) => a.name).join(', ') : 'Unknown Artist';
|
|
details = `<span>${albumItem.total_tracks || 0} tracks</span><span>${albumItem.release_date ? new Date(albumItem.release_date).getFullYear() : ''}</span>`;
|
|
}
|
|
break;
|
|
case 'playlist':
|
|
{
|
|
const playlistItem = item as PlaylistResultItem;
|
|
subtitle = `By ${playlistItem.owner ? playlistItem.owner.display_name : 'Unknown'}`;
|
|
details = `<span>${playlistItem.tracks && playlistItem.tracks.total ? playlistItem.tracks.total : 0} tracks</span>`;
|
|
}
|
|
break;
|
|
case 'artist':
|
|
{
|
|
const artistItem = item as ArtistResultItem;
|
|
subtitle = 'Artist';
|
|
details = artistItem.genres ? `<span>${artistItem.genres.slice(0, 2).join(', ')}</span>` : '';
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Build the HTML
|
|
cardElement.innerHTML = `
|
|
<div class="album-art-wrapper">
|
|
<img class="album-art" src="${imageUrl}" alt="${item.name || 'Item'}" onerror="this.src='/static/images/placeholder.jpg'">
|
|
</div>
|
|
<div class="track-title">${item.name || 'Unknown'}</div>
|
|
<div class="track-artist">${subtitle}</div>
|
|
<div class="track-details">${details}</div>
|
|
<button class="download-btn btn-primary" data-item-index="${index}">
|
|
<img src="/static/images/download.svg" alt="Download" />
|
|
Download
|
|
</button>
|
|
`;
|
|
|
|
// Add click event to navigate to the item's detail page
|
|
cardElement.addEventListener('click', (e: MouseEvent) => {
|
|
// Don't trigger if the download button was clicked
|
|
const target = e.target as HTMLElement;
|
|
if (target.classList.contains('download-btn') ||
|
|
target.parentElement?.classList.contains('download-btn')) {
|
|
return;
|
|
}
|
|
|
|
if (item.id) {
|
|
window.location.href = `/${type}/${item.id}`;
|
|
}
|
|
});
|
|
|
|
return cardElement;
|
|
}
|
|
|
|
/**
|
|
* Show/hide the empty state
|
|
*/
|
|
function showEmptyState(show: boolean) {
|
|
if (emptyState) {
|
|
emptyState.style.display = show ? 'flex' : 'none';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show/hide the loading indicator
|
|
*/
|
|
function showLoading(show: boolean) {
|
|
if (loadingResults) {
|
|
loadingResults.classList.toggle('hidden', !show);
|
|
}
|
|
}
|
|
});
|