From 99568cb8e28ebaa686322b5448449b4496b6f414 Mon Sep 17 00:00:00 2001
From: J0Z1L
Date: Sat, 28 Feb 2026 00:33:16 +0100
Subject: [PATCH] Add library presence scan, playlist import, responsive
add/remove actions and job status UI
---
.env.example | 1 +
README.md | 3 +
docker-compose.yml | 1 +
public/app.js | 270 ++++++++++++++++++++++-------
public/index.html | 20 +++
public/styles.css | 30 ++++
scripts/run-unraid.sh | 3 +
server.js | 389 +++++++++++++++++++++++++++++++++++++-----
8 files changed, 613 insertions(+), 104 deletions(-)
diff --git a/.env.example b/.env.example
index 2e00863..381a9a3 100644
--- a/.env.example
+++ b/.env.example
@@ -2,6 +2,7 @@ PORT=3000
SPOTIFY_CLIENT_ID=deine_spotify_client_id
SPOTIFY_CLIENT_SECRET=dein_spotify_client_secret
+YOUTUBE_API_KEY=
LIDARR_URL=http://lidarr:8686
LIDARR_API_KEY=dein_lidarr_api_key
diff --git a/README.md b/README.md
index 8711ac2..f322e6b 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,9 @@ Web-Frontend, um Alben aus Spotify zu suchen und an Lidarr zu uebergeben.
- Einstellung im Frontend: ueberfluessige Dateien nach Download loeschen (optional)
- Button fuer Lidarr Bibliothek-Update
- Button fuer Frontend Git-Update (fetch/pull)
+- Bibliotheksscan und \"Vorhanden/Fehlt\" Markierung
+- Playlist-Import (Spotify, YouTube mit API-Key)
+- Job-Statusanzeige fuer Downloads
- Docker-ready
## Voraussetzungen
diff --git a/docker-compose.yml b/docker-compose.yml
index a2285a6..71b17e2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -9,6 +9,7 @@ services:
- PORT=3000
- SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
- SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
+ - YOUTUBE_API_KEY=${YOUTUBE_API_KEY}
- LIDARR_URL=${LIDARR_URL}
- LIDARR_API_KEY=${LIDARR_API_KEY}
- LIDARR_ROOT_FOLDER=${LIDARR_ROOT_FOLDER}
diff --git a/public/app.js b/public/app.js
index f8a210e..9e2aa42 100644
--- a/public/app.js
+++ b/public/app.js
@@ -11,13 +11,21 @@ const sendBtn = document.getElementById('sendBtn');
const cleanupToggle = document.getElementById('cleanupToggle');
const updateLibraryBtn = document.getElementById('updateLibraryBtn');
const updateFrontendBtn = document.getElementById('updateFrontendBtn');
+const scanLibraryBtn = document.getElementById('scanLibraryBtn');
+const playlistSource = document.getElementById('playlistSource');
+const playlistValue = document.getElementById('playlistValue');
+const importPlaylistBtn = document.getElementById('importPlaylistBtn');
+const sendPlaylistBtn = document.getElementById('sendPlaylistBtn');
+const playlistActions = document.getElementById('playlistActions');
+const jobList = document.getElementById('jobList');
let selectedAlbum = null;
let sendContext = null;
+let playlistTracks = [];
+let jobs = [];
const CLEANUP_KEY = 'cleanupExtras';
cleanupToggle.checked = localStorage.getItem(CLEANUP_KEY) === 'true';
-
cleanupToggle.addEventListener('change', () => {
localStorage.setItem(CLEANUP_KEY, String(cleanupToggle.checked));
});
@@ -43,20 +51,23 @@ async function fetchJson(url, options = {}) {
return data;
}
+function pill(exists) {
+ return `${exists ? 'Vorhanden' : 'Fehlt'}`;
+}
+
function createAlbumCard(album, preselectedTrackNames = []) {
const card = document.createElement('article');
card.className = 'album-card';
-
card.innerHTML = `
${album.image ? `
` : ''}
${album.name}
${album.artist}
${album.totalTracks} Tracks - ${album.releaseDate || 'unbekannt'}
+
${pill(Boolean(album.exists))}
`;
-
card.querySelector('button').addEventListener('click', () =>
openAlbumDialog(album.id, preselectedTrackNames, { albumName: album.name, artistName: album.artist })
);
@@ -66,17 +77,16 @@ function createAlbumCard(album, preselectedTrackNames = []) {
function createTrackCard(track) {
const card = document.createElement('article');
card.className = 'album-card';
-
card.innerHTML = `
${track.image ? `
` : ''}
${track.trackName}
${track.artist}
Album: ${track.albumName}
+
${pill(Boolean(track.exists))}
`;
-
card.querySelector('button').addEventListener('click', () =>
openAlbumDialog(track.albumId, [track.trackName], {
albumName: track.albumName,
@@ -89,29 +99,40 @@ function createTrackCard(track) {
function createArtistCard(artist) {
const card = document.createElement('article');
card.className = 'album-card';
-
card.innerHTML = `
${artist.image ? `
` : ''}
${artist.name}
${artist.followers || 0} Follower
-
Artist
`;
-
card.querySelector('button').addEventListener('click', () => loadArtistAlbums(artist.id, artist.name));
return card;
}
+function createPlaylistTrackCard(track, idx) {
+ const card = document.createElement('article');
+ card.className = 'album-card';
+ card.innerHTML = `
+ ${track.image ? `
` : ''}
+
+
${track.trackName}
+
${track.artist}
+
Album: ${track.albumName}
+
${pill(Boolean(track.exists))}
+
+
+ `;
+ return card;
+}
+
function renderItems(type, items) {
results.innerHTML = '';
-
if (!items.length) {
results.innerHTML = 'Keine Treffer gefunden.
';
return;
}
-
for (const item of items) {
let card;
if (type === 'track') card = createTrackCard(item);
@@ -121,34 +142,58 @@ function renderItems(type, items) {
}
}
+function updateDialogActionButton() {
+ const rows = Array.from(trackList.querySelectorAll('input[type="checkbox"]'));
+ const selected = rows.filter((x) => x.checked).map((x) => ({
+ exists: x.dataset.exists === 'true'
+ }));
+ const add = selected.filter((x) => !x.exists).length;
+ const keep = selected.filter((x) => x.exists).length;
+ const remove = rows.filter((x) => !x.checked && x.dataset.exists === 'true').length;
+
+ if (add > 0 && remove > 0) {
+ sendBtn.textContent = `Anwenden (+${add} / -${remove})`;
+ } else if (add > 0) {
+ sendBtn.textContent = `Hinzufuegen (${add})`;
+ } else if (remove > 0) {
+ sendBtn.textContent = `Entfernen (${remove})`;
+ } else if (keep > 0) {
+ sendBtn.textContent = `Behalten (${keep})`;
+ } else {
+ sendBtn.textContent = 'An Lidarr senden';
+ }
+}
+
async function openAlbumDialog(albumId, preselectedTrackNames = [], context = null) {
setStatus('Lade Albumdetails...');
-
try {
const album = await fetchJson(`/api/spotify/album/${albumId}`);
- const preselected = new Set(preselectedTrackNames || []);
-
selectedAlbum = album;
sendContext = context;
dialogTitle.textContent = album.name;
dialogArtist.textContent = `Artist: ${album.artist}`;
+ const preselected = new Set(preselectedTrackNames || []);
trackList.innerHTML = '';
for (const track of album.tracks) {
const row = document.createElement('label');
row.className = 'track-row';
const isChecked = preselected.size > 0 ? preselected.has(track.name) : true;
row.innerHTML = `
-
+
${track.trackNumber}. ${track.name}
(${formatDuration(track.durationMs)})
+ ${pill(Boolean(track.exists))}
`;
+ const input = row.querySelector('input');
+ input.addEventListener('change', updateDialogActionButton);
trackList.appendChild(row);
}
+ updateDialogActionButton();
dialog.showModal();
- setStatus('Album bereit. Tracks auswaehlen und senden.');
+ setStatus('Album bereit. Auswahl pruefen und senden.');
} catch (err) {
setStatus(err.message, true);
}
@@ -156,7 +201,6 @@ async function openAlbumDialog(albumId, preselectedTrackNames = [], context = nu
async function loadArtistAlbums(artistId, artistName) {
setStatus(`Lade Alben von ${artistName}...`);
-
try {
const data = await fetchJson(`/api/spotify/artist/${artistId}/albums`);
renderItems('album', data.albums || []);
@@ -169,44 +213,69 @@ async function loadArtistAlbums(artistId, artistName) {
async function searchSpotify() {
const q = queryInput.value.trim();
const type = searchTypeSelect.value;
-
- if (!q) {
- setStatus('Bitte Suchbegriff eingeben.', true);
- return;
- }
+ if (!q) return setStatus('Bitte Suchbegriff eingeben.', true);
setStatus('Suche in Spotify...');
results.innerHTML = '';
-
try {
const data = await fetchJson(`/api/spotify/search?q=${encodeURIComponent(q)}&type=${encodeURIComponent(type)}`);
- const items = data.items || [];
- renderItems(type, items);
- setStatus(`${items.length} Treffer gefunden (${type}).`);
+ renderItems(type, data.items || []);
+ setStatus(`${(data.items || []).length} Treffer gefunden (${type}).`);
} catch (err) {
setStatus(err.message, true);
}
}
+function addJob(job) {
+ jobs.unshift(job);
+ if (jobs.length > 30) jobs = jobs.slice(0, 30);
+ renderJobs();
+}
+
+function renderJobs() {
+ jobList.innerHTML = '';
+ for (const job of jobs) {
+ const row = document.createElement('div');
+ row.className = 'job-row';
+ row.textContent = `${job.label}: ${job.status}`;
+ jobList.appendChild(row);
+ }
+}
+
+async function pollCommand(commandId, label) {
+ if (!commandId) return;
+ const job = { label, status: 'queued', commandId };
+ addJob(job);
+
+ for (let i = 0; i < 60; i += 1) {
+ try {
+ const data = await fetchJson(`/api/lidarr/command/${commandId}`);
+ job.status = data.status || 'unknown';
+ renderJobs();
+ if (['completed', 'failed', 'aborted'].includes(String(data.status || '').toLowerCase())) return;
+ } catch (_err) {
+ job.status = 'status-unbekannt';
+ renderJobs();
+ return;
+ }
+ await new Promise((r) => setTimeout(r, 3000));
+ }
+}
+
async function sendToLidarr(event) {
event.preventDefault();
+ if (!selectedAlbum) return setStatus('Kein Album ausgewaehlt.', true);
- if (!selectedAlbum) {
- setStatus('Kein Album ausgewaehlt.', true);
- return;
- }
+ const rows = Array.from(trackList.querySelectorAll('input[type="checkbox"]'));
+ const trackStates = rows.map((input) => ({
+ name: input.dataset.trackName,
+ selected: input.checked,
+ exists: input.dataset.exists === 'true'
+ }));
+ const selectedTrackNames = trackStates.filter((x) => x.selected).map((x) => x.name);
- const checked = Array.from(trackList.querySelectorAll('input[type="checkbox"]:checked'));
- const selectedTrackNames = checked.map((input) => input.dataset.trackName);
-
- if (!selectedTrackNames.length) {
- setStatus('Bitte mindestens einen Track auswaehlen.', true);
- return;
- }
-
- setStatus('Sende Album an Lidarr...');
+ setStatus('Sende Auswahl an Lidarr...');
sendBtn.disabled = true;
-
try {
const data = await fetchJson('/api/lidarr/send-album', {
method: 'POST',
@@ -215,16 +284,14 @@ async function sendToLidarr(event) {
albumName: sendContext?.albumName || selectedAlbum.name,
artistName: sendContext?.artistName || selectedAlbum.artist,
selectedTrackNames,
+ trackStates,
cleanupExtras: cleanupToggle.checked
})
});
- const cleanupMsg = data.cleanup?.attempted
- ? ` Cleanup: ${data.cleanup.deleted} Dateien geloescht.`
- : '';
-
- setStatus(`Album erfolgreich an Lidarr uebergeben (ID ${data.albumId}).${cleanupMsg}`);
+ setStatus(`Aktion gespeichert (+${data.trackActions.add} / -${data.trackActions.remove} / =${data.trackActions.keep}).`);
dialog.close();
+ pollCommand(data.commandId, `${selectedAlbum.name}`);
} catch (err) {
setStatus(`Fehler: ${err.message}`, true);
} finally {
@@ -232,15 +299,24 @@ async function sendToLidarr(event) {
}
}
+async function scanLibrary() {
+ scanLibraryBtn.disabled = true;
+ setStatus('Scanne Lidarr Bibliothek...');
+ try {
+ const data = await fetchJson('/api/lidarr/library-scan', { method: 'POST' });
+ setStatus(`Bibliothek gescannt (${data.trackCount} Tracks).`);
+ } catch (err) {
+ setStatus(`Scan fehlgeschlagen: ${err.message}`, true);
+ } finally {
+ scanLibraryBtn.disabled = false;
+ }
+}
+
async function updateLibrary() {
setStatus('Starte Lidarr Bibliothek-Update...');
updateLibraryBtn.disabled = true;
-
try {
- const data = await fetchJson('/api/lidarr/update-library', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' }
- });
+ const data = await fetchJson('/api/lidarr/update-library', { method: 'POST' });
setStatus(`Bibliothek-Update gestartet (${data.commandNames.join(', ')}).`);
} catch (err) {
setStatus(`Fehler beim Bibliothek-Update: ${err.message}`, true);
@@ -252,13 +328,8 @@ async function updateLibrary() {
async function updateFrontendFromGit() {
setStatus('Hole aktuelle Git-Daten...');
updateFrontendBtn.disabled = true;
-
try {
- const data = await fetchJson('/api/system/update-frontend', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' }
- });
-
+ const data = await fetchJson('/api/system/update-frontend', { method: 'POST' });
if (data.updated) {
setStatus(`Frontend aktualisiert (${data.before} -> ${data.after}). Seite wird neu geladen...`);
setTimeout(() => window.location.reload(), 1200);
@@ -272,12 +343,95 @@ async function updateFrontendFromGit() {
}
}
+function renderPlaylist() {
+ results.innerHTML = '';
+ if (!playlistTracks.length) {
+ results.innerHTML = 'Keine Playlist-Tracks gefunden.
';
+ playlistActions.style.display = 'none';
+ return;
+ }
+ playlistTracks.forEach((track, idx) => {
+ const card = createPlaylistTrackCard(track, idx);
+ const cb = card.querySelector('input[type="checkbox"]');
+ cb.addEventListener('change', () => {
+ playlistTracks[idx].selected = cb.checked;
+ });
+ results.appendChild(card);
+ });
+ playlistActions.style.display = 'block';
+}
+
+async function importPlaylist() {
+ const source = playlistSource.value;
+ const value = playlistValue.value.trim();
+ if (!value) return setStatus('Bitte Playlist URL oder ID eingeben.', true);
+
+ importPlaylistBtn.disabled = true;
+ setStatus(`Importiere ${source}-Playlist...`);
+ try {
+ const data = await fetchJson('/api/import/playlist', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ source, value })
+ });
+ playlistTracks = (data.tracks || []).map((t) => ({ ...t, selected: true }));
+ renderPlaylist();
+ setStatus(`${playlistTracks.length} Tracks importiert.`);
+ } catch (err) {
+ setStatus(`Playlist-Import fehlgeschlagen: ${err.message}`, true);
+ } finally {
+ importPlaylistBtn.disabled = false;
+ }
+}
+
+async function sendPlaylistSelection() {
+ const selected = playlistTracks.filter((t) => t.selected);
+ if (!selected.length) return setStatus('Keine Playlist-Tracks ausgewaehlt.', true);
+
+ const groups = new Map();
+ for (const track of selected) {
+ const key = `${track.albumName}___${track.albumArtist}`;
+ if (!groups.has(key)) {
+ groups.set(key, {
+ albumName: track.albumName,
+ artistName: track.albumArtist,
+ selectedTrackNames: [],
+ trackStates: []
+ });
+ }
+ const g = groups.get(key);
+ g.selectedTrackNames.push(track.trackName);
+ g.trackStates.push({ name: track.trackName, selected: true, exists: Boolean(track.exists) });
+ }
+
+ setStatus(`Sende ${groups.size} Alben aus Playlist an Lidarr...`);
+ sendPlaylistBtn.disabled = true;
+ try {
+ for (const item of groups.values()) {
+ const data = await fetchJson('/api/lidarr/send-album', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(item)
+ });
+ pollCommand(data.commandId, `${item.artistName} - ${item.albumName}`);
+ }
+ setStatus('Playlist-Auswahl wurde an Lidarr uebergeben.');
+ } catch (err) {
+ setStatus(`Fehler bei Playlist-Download: ${err.message}`, true);
+ } finally {
+ sendPlaylistBtn.disabled = false;
+ }
+}
+
searchBtn.addEventListener('click', searchSpotify);
queryInput.addEventListener('keydown', (event) => {
- if (event.key === 'Enter') {
- searchSpotify();
- }
+ if (event.key === 'Enter') searchSpotify();
});
sendBtn.addEventListener('click', sendToLidarr);
+scanLibraryBtn.addEventListener('click', scanLibrary);
updateLibraryBtn.addEventListener('click', updateLibrary);
updateFrontendBtn.addEventListener('click', updateFrontendFromGit);
+importPlaylistBtn.addEventListener('click', importPlaylist);
+sendPlaylistBtn.addEventListener('click', sendPlaylistSelection);
+
+renderJobs();
diff --git a/public/index.html b/public/index.html
index 5dacad5..d052bb6 100644
--- a/public/index.html
+++ b/public/index.html
@@ -23,6 +23,7 @@
Ueberfluessige Dateien nach Download loeschen
+
@@ -31,6 +32,21 @@
+
+
Spotify suchen
@@ -50,6 +66,10 @@
+