Add Spotify search type filter for album/track/artist with album-based Lidarr flow
This commit is contained in:
parent
b3a343c3a0
commit
abf9d61d72
4 changed files with 167 additions and 40 deletions
119
public/app.js
119
public/app.js
|
|
@ -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, '"')}" />
|
<input type="checkbox" ${isChecked ? 'checked' : ''} data-track-name="${track.name.replace(/"/g, '"')}" />
|
||||||
<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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
74
server.js
74
server.js
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue