Add Spotify search type filter for album/track/artist with album-based Lidarr flow

This commit is contained in:
J0Z1L 2026-02-28 00:07:18 +01:00
parent b3a343c3a0
commit abf9d61d72
4 changed files with 167 additions and 40 deletions

View file

@ -1,5 +1,6 @@
const queryInput = document.getElementById('query'); const queryInput = document.getElementById('query');
const searchBtn = document.getElementById('searchBtn'); const searchBtn = document.getElementById('searchBtn');
const searchTypeSelect = document.getElementById('searchType');
const results = document.getElementById('results'); const results = document.getElementById('results');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const dialog = document.getElementById('albumDialog'); const dialog = document.getElementById('albumDialog');
@ -10,7 +11,6 @@ const sendBtn = document.getElementById('sendBtn');
const cleanupToggle = document.getElementById('cleanupToggle'); const cleanupToggle = document.getElementById('cleanupToggle');
let selectedAlbum = null; let selectedAlbum = null;
let selectedTracks = [];
const CLEANUP_KEY = 'cleanupExtras'; const CLEANUP_KEY = 'cleanupExtras';
cleanupToggle.checked = localStorage.getItem(CLEANUP_KEY) === 'true'; cleanupToggle.checked = localStorage.getItem(CLEANUP_KEY) === 'true';
@ -40,40 +40,85 @@ async function fetchJson(url, options = {}) {
return data; return data;
} }
function renderAlbums(albums) { function createAlbumCard(album, preselectedTrackNames = []) {
const card = document.createElement('article');
card.className = 'album-card';
card.innerHTML = `
${album.image ? `<img class="album-cover" src="${album.image}" alt="${album.name}" />` : '<div class="album-cover"></div>'}
<div class="album-body">
<h3>${album.name}</h3>
<p>${album.artist}</p>
<p>${album.totalTracks} Tracks - ${album.releaseDate || 'unbekannt'}</p>
<button type="button">Album auswaehlen</button>
</div>
`;
card.querySelector('button').addEventListener('click', () => openAlbumDialog(album.id, preselectedTrackNames));
return card;
}
function createTrackCard(track) {
const card = document.createElement('article');
card.className = 'album-card';
card.innerHTML = `
${track.image ? `<img class="album-cover" src="${track.image}" alt="${track.albumName}" />` : '<div class="album-cover"></div>'}
<div class="album-body">
<h3>${track.trackName}</h3>
<p>${track.artist}</p>
<p>Album: ${track.albumName}</p>
<button type="button">Album fuer Track auswaehlen</button>
</div>
`;
card.querySelector('button').addEventListener('click', () => openAlbumDialog(track.albumId, [track.trackName]));
return card;
}
function createArtistCard(artist) {
const card = document.createElement('article');
card.className = 'album-card';
card.innerHTML = `
${artist.image ? `<img class="album-cover" src="${artist.image}" alt="${artist.name}" />` : '<div class="album-cover"></div>'}
<div class="album-body">
<h3>${artist.name}</h3>
<p>${artist.followers || 0} Follower</p>
<p>Artist</p>
<button type="button">Alben anzeigen</button>
</div>
`;
card.querySelector('button').addEventListener('click', () => loadArtistAlbums(artist.id, artist.name));
return card;
}
function renderItems(type, items) {
results.innerHTML = ''; results.innerHTML = '';
if (!albums.length) { if (!items.length) {
results.innerHTML = '<p>Keine Alben gefunden.</p>'; results.innerHTML = '<p>Keine Treffer gefunden.</p>';
return; return;
} }
for (const album of albums) { for (const item of items) {
const card = document.createElement('article'); let card;
card.className = 'album-card'; if (type === 'track') card = createTrackCard(item);
if (type === 'artist') card = createArtistCard(item);
card.innerHTML = ` if (type === 'album') card = createAlbumCard(item);
${album.image ? `<img class="album-cover" src="${album.image}" alt="${album.name}" />` : '<div class="album-cover"></div>'} if (card) results.appendChild(card);
<div class="album-body">
<h3>${album.name}</h3>
<p>${album.artist}</p>
<p>${album.totalTracks} Tracks - ${album.releaseDate || 'unbekannt'}</p>
<button type="button">Auswaehlen</button>
</div>
`;
card.querySelector('button').addEventListener('click', () => openAlbumDialog(album.id));
results.appendChild(card);
} }
} }
async function openAlbumDialog(albumId) { async function openAlbumDialog(albumId, preselectedTrackNames = []) {
setStatus('Lade Albumdetails...'); setStatus('Lade Albumdetails...');
try { try {
const album = await fetchJson(`/api/spotify/album/${albumId}`); const album = await fetchJson(`/api/spotify/album/${albumId}`);
const preselected = new Set(preselectedTrackNames || []);
selectedAlbum = album; selectedAlbum = album;
selectedTracks = album.tracks.map((t) => t.name);
dialogTitle.textContent = album.name; dialogTitle.textContent = album.name;
dialogArtist.textContent = `Artist: ${album.artist}`; dialogArtist.textContent = `Artist: ${album.artist}`;
@ -82,8 +127,9 @@ async function openAlbumDialog(albumId) {
for (const track of album.tracks) { for (const track of album.tracks) {
const row = document.createElement('label'); const row = document.createElement('label');
row.className = 'track-row'; row.className = 'track-row';
const isChecked = preselected.size > 0 ? preselected.has(track.name) : true;
row.innerHTML = ` row.innerHTML = `
<input type="checkbox" checked data-track-name="${track.name.replace(/"/g, '&quot;')}" /> <input type="checkbox" ${isChecked ? 'checked' : ''} data-track-name="${track.name.replace(/"/g, '&quot;')}" />
<span>${track.trackNumber}. ${track.name}</span> <span>${track.trackNumber}. ${track.name}</span>
<small>(${formatDuration(track.durationMs)})</small> <small>(${formatDuration(track.durationMs)})</small>
`; `;
@ -97,8 +143,22 @@ async function openAlbumDialog(albumId) {
} }
} }
async function searchAlbums() { async function loadArtistAlbums(artistId, artistName) {
setStatus(`Lade Alben von ${artistName}...`);
try {
const data = await fetchJson(`/api/spotify/artist/${artistId}/albums`);
renderItems('album', data.albums || []);
setStatus(`${data.albums.length} Alben von ${artistName} gefunden.`);
} catch (err) {
setStatus(err.message, true);
}
}
async function searchSpotify() {
const q = queryInput.value.trim(); const q = queryInput.value.trim();
const type = searchTypeSelect.value;
if (!q) { if (!q) {
setStatus('Bitte Suchbegriff eingeben.', true); setStatus('Bitte Suchbegriff eingeben.', true);
return; return;
@ -108,9 +168,10 @@ async function searchAlbums() {
results.innerHTML = ''; results.innerHTML = '';
try { try {
const data = await fetchJson(`/api/spotify/search?q=${encodeURIComponent(q)}`); const data = await fetchJson(`/api/spotify/search?q=${encodeURIComponent(q)}&type=${encodeURIComponent(type)}`);
renderAlbums(data.albums || []); const items = data.items || [];
setStatus(`${data.albums.length} Alben gefunden.`); renderItems(type, items);
setStatus(`${items.length} Treffer gefunden (${type}).`);
} catch (err) { } catch (err) {
setStatus(err.message, true); setStatus(err.message, true);
} }
@ -160,10 +221,10 @@ async function sendToLidarr(event) {
} }
} }
searchBtn.addEventListener('click', searchAlbums); searchBtn.addEventListener('click', searchSpotify);
queryInput.addEventListener('keydown', (event) => { queryInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
searchAlbums(); searchSpotify();
} }
}); });
sendBtn.addEventListener('click', sendToLidarr); sendBtn.addEventListener('click', sendToLidarr);

View file

@ -28,8 +28,13 @@
</section> </section>
<section class="panel search"> <section class="panel search">
<h2>Album suchen</h2> <h2>Spotify suchen</h2>
<div class="search-row"> <div class="search-row">
<select id="searchType">
<option value="album">Alben</option>
<option value="track">Tracks</option>
<option value="artist">Interpreten</option>
</select>
<input id="query" placeholder="Album oder Artist eingeben" /> <input id="query" placeholder="Album oder Artist eingeben" />
<button id="searchBtn">Suchen</button> <button id="searchBtn">Suchen</button>
</div> </div>

View file

@ -66,6 +66,7 @@ body {
} }
input, input,
select,
button { button {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
@ -80,6 +81,12 @@ input {
flex: 1; flex: 1;
} }
select {
background: #0b1220;
color: var(--text);
min-width: 140px;
}
button { button {
background: linear-gradient(120deg, var(--accent), var(--accent-2)); background: linear-gradient(120deg, var(--accent), var(--accent-2));
color: #001018; color: #001018;

View file

@ -247,28 +247,82 @@ app.get('/api/health', (_req, res) => {
app.get('/api/spotify/search', async (req, res) => { app.get('/api/spotify/search', async (req, res) => {
const q = String(req.query.q || '').trim(); const q = String(req.query.q || '').trim();
const type = String(req.query.type || 'album').trim();
const allowedTypes = new Set(['album', 'track', 'artist']);
if (!q) { if (!q) {
return res.status(400).json({ error: 'Suchbegriff fehlt.' }); return res.status(400).json({ error: 'Suchbegriff fehlt.' });
} }
if (!allowedTypes.has(type)) {
return res.status(400).json({ error: 'Ungueltiger Suchtyp. Erlaubt: album, track, artist.' });
}
try { try {
const data = await spotifyRequest('/search', { const data = await spotifyRequest('/search', {
q, q,
type: 'album', type,
limit: 20, limit: 20,
market: 'DE' market: 'DE'
}); });
const albums = (data.albums?.items || []).map((album) => ({ let items = [];
id: album.id, if (type === 'album') {
name: album.name, items = (data.albums?.items || []).map((album) => ({
artist: album.artists?.map((a) => a.name).join(', ') || 'Unbekannt', id: album.id,
image: album.images?.[1]?.url || album.images?.[0]?.url || null, name: album.name,
releaseDate: album.release_date, artist: album.artists?.map((a) => a.name).join(', ') || 'Unbekannt',
totalTracks: album.total_tracks image: album.images?.[1]?.url || album.images?.[0]?.url || null,
})); releaseDate: album.release_date,
totalTracks: album.total_tracks
}));
}
if (type === 'track') {
items = (data.tracks?.items || []).map((track) => ({
id: track.id,
trackName: track.name,
artist: track.artists?.map((a) => a.name).join(', ') || 'Unbekannt',
albumId: track.album?.id,
albumName: track.album?.name || 'Unbekannt',
image: track.album?.images?.[1]?.url || track.album?.images?.[0]?.url || null
}));
}
if (type === 'artist') {
items = (data.artists?.items || []).map((artist) => ({
id: artist.id,
name: artist.name,
followers: artist.followers?.total || 0,
image: artist.images?.[1]?.url || artist.images?.[0]?.url || null
}));
}
res.json({ albums }); res.json({ items });
} catch (err) {
res.status(500).json({ error: err.response?.data?.error?.message || err.message });
}
});
app.get('/api/spotify/artist/:id/albums', async (req, res) => {
try {
const data = await spotifyRequest(`/artists/${req.params.id}/albums`, {
include_groups: 'album,single',
limit: 50,
market: 'DE'
});
const unique = new Map();
for (const album of data.items || []) {
if (unique.has(album.id)) continue;
unique.set(album.id, {
id: album.id,
name: album.name,
artist: album.artists?.map((a) => a.name).join(', ') || 'Unbekannt',
image: album.images?.[1]?.url || album.images?.[0]?.url || null,
releaseDate: album.release_date,
totalTracks: album.total_tracks
});
}
res.json({ albums: Array.from(unique.values()) });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.response?.data?.error?.message || err.message }); res.status(500).json({ error: err.response?.data?.error?.message || err.message });
} }