Add library presence scan, playlist import, responsive add/remove actions and job status UI

This commit is contained in:
J0Z1L 2026-02-28 00:33:16 +01:00
parent 083dcbd992
commit 99568cb8e2
8 changed files with 613 additions and 104 deletions

View file

@ -2,6 +2,7 @@ PORT=3000
SPOTIFY_CLIENT_ID=deine_spotify_client_id SPOTIFY_CLIENT_ID=deine_spotify_client_id
SPOTIFY_CLIENT_SECRET=dein_spotify_client_secret SPOTIFY_CLIENT_SECRET=dein_spotify_client_secret
YOUTUBE_API_KEY=
LIDARR_URL=http://lidarr:8686 LIDARR_URL=http://lidarr:8686
LIDARR_API_KEY=dein_lidarr_api_key LIDARR_API_KEY=dein_lidarr_api_key

View file

@ -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) - Einstellung im Frontend: ueberfluessige Dateien nach Download loeschen (optional)
- Button fuer Lidarr Bibliothek-Update - Button fuer Lidarr Bibliothek-Update
- Button fuer Frontend Git-Update (fetch/pull) - 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 - Docker-ready
## Voraussetzungen ## Voraussetzungen

View file

@ -9,6 +9,7 @@ services:
- PORT=3000 - PORT=3000
- SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID} - SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
- SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET} - SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
- YOUTUBE_API_KEY=${YOUTUBE_API_KEY}
- LIDARR_URL=${LIDARR_URL} - LIDARR_URL=${LIDARR_URL}
- LIDARR_API_KEY=${LIDARR_API_KEY} - LIDARR_API_KEY=${LIDARR_API_KEY}
- LIDARR_ROOT_FOLDER=${LIDARR_ROOT_FOLDER} - LIDARR_ROOT_FOLDER=${LIDARR_ROOT_FOLDER}

View file

@ -11,13 +11,21 @@ const sendBtn = document.getElementById('sendBtn');
const cleanupToggle = document.getElementById('cleanupToggle'); const cleanupToggle = document.getElementById('cleanupToggle');
const updateLibraryBtn = document.getElementById('updateLibraryBtn'); const updateLibraryBtn = document.getElementById('updateLibraryBtn');
const updateFrontendBtn = document.getElementById('updateFrontendBtn'); 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 selectedAlbum = null;
let sendContext = null; let sendContext = null;
let playlistTracks = [];
let jobs = [];
const CLEANUP_KEY = 'cleanupExtras'; const CLEANUP_KEY = 'cleanupExtras';
cleanupToggle.checked = localStorage.getItem(CLEANUP_KEY) === 'true'; cleanupToggle.checked = localStorage.getItem(CLEANUP_KEY) === 'true';
cleanupToggle.addEventListener('change', () => { cleanupToggle.addEventListener('change', () => {
localStorage.setItem(CLEANUP_KEY, String(cleanupToggle.checked)); localStorage.setItem(CLEANUP_KEY, String(cleanupToggle.checked));
}); });
@ -43,20 +51,23 @@ async function fetchJson(url, options = {}) {
return data; return data;
} }
function pill(exists) {
return `<span class="pill ${exists ? 'exists' : 'missing'}">${exists ? 'Vorhanden' : 'Fehlt'}</span>`;
}
function createAlbumCard(album, preselectedTrackNames = []) { function createAlbumCard(album, preselectedTrackNames = []) {
const card = document.createElement('article'); const card = document.createElement('article');
card.className = 'album-card'; card.className = 'album-card';
card.innerHTML = ` card.innerHTML = `
${album.image ? `<img class="album-cover" src="${album.image}" alt="${album.name}" />` : '<div class="album-cover"></div>'} ${album.image ? `<img class="album-cover" src="${album.image}" alt="${album.name}" />` : '<div class="album-cover"></div>'}
<div class="album-body"> <div class="album-body">
<h3>${album.name}</h3> <h3>${album.name}</h3>
<p>${album.artist}</p> <p>${album.artist}</p>
<p>${album.totalTracks} Tracks - ${album.releaseDate || 'unbekannt'}</p> <p>${album.totalTracks} Tracks - ${album.releaseDate || 'unbekannt'}</p>
<p>${pill(Boolean(album.exists))}</p>
<button type="button">Album auswaehlen</button> <button type="button">Album auswaehlen</button>
</div> </div>
`; `;
card.querySelector('button').addEventListener('click', () => card.querySelector('button').addEventListener('click', () =>
openAlbumDialog(album.id, preselectedTrackNames, { albumName: album.name, artistName: album.artist }) openAlbumDialog(album.id, preselectedTrackNames, { albumName: album.name, artistName: album.artist })
); );
@ -66,17 +77,16 @@ function createAlbumCard(album, preselectedTrackNames = []) {
function createTrackCard(track) { function createTrackCard(track) {
const card = document.createElement('article'); const card = document.createElement('article');
card.className = 'album-card'; card.className = 'album-card';
card.innerHTML = ` card.innerHTML = `
${track.image ? `<img class="album-cover" src="${track.image}" alt="${track.albumName}" />` : '<div class="album-cover"></div>'} ${track.image ? `<img class="album-cover" src="${track.image}" alt="${track.albumName}" />` : '<div class="album-cover"></div>'}
<div class="album-body"> <div class="album-body">
<h3>${track.trackName}</h3> <h3>${track.trackName}</h3>
<p>${track.artist}</p> <p>${track.artist}</p>
<p>Album: ${track.albumName}</p> <p>Album: ${track.albumName}</p>
<p>${pill(Boolean(track.exists))}</p>
<button type="button">Album fuer Track auswaehlen</button> <button type="button">Album fuer Track auswaehlen</button>
</div> </div>
`; `;
card.querySelector('button').addEventListener('click', () => card.querySelector('button').addEventListener('click', () =>
openAlbumDialog(track.albumId, [track.trackName], { openAlbumDialog(track.albumId, [track.trackName], {
albumName: track.albumName, albumName: track.albumName,
@ -89,29 +99,40 @@ function createTrackCard(track) {
function createArtistCard(artist) { function createArtistCard(artist) {
const card = document.createElement('article'); const card = document.createElement('article');
card.className = 'album-card'; card.className = 'album-card';
card.innerHTML = ` card.innerHTML = `
${artist.image ? `<img class="album-cover" src="${artist.image}" alt="${artist.name}" />` : '<div class="album-cover"></div>'} ${artist.image ? `<img class="album-cover" src="${artist.image}" alt="${artist.name}" />` : '<div class="album-cover"></div>'}
<div class="album-body"> <div class="album-body">
<h3>${artist.name}</h3> <h3>${artist.name}</h3>
<p>${artist.followers || 0} Follower</p> <p>${artist.followers || 0} Follower</p>
<p>Artist</p>
<button type="button">Alben anzeigen</button> <button type="button">Alben anzeigen</button>
</div> </div>
`; `;
card.querySelector('button').addEventListener('click', () => loadArtistAlbums(artist.id, artist.name)); card.querySelector('button').addEventListener('click', () => loadArtistAlbums(artist.id, artist.name));
return card; return card;
} }
function createPlaylistTrackCard(track, idx) {
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>
<p>${pill(Boolean(track.exists))}</p>
<label><input type="checkbox" data-playlist-idx="${idx}" ${track.selected ? 'checked' : ''}/> Auswaehlen</label>
</div>
`;
return card;
}
function renderItems(type, items) { function renderItems(type, items) {
results.innerHTML = ''; results.innerHTML = '';
if (!items.length) { if (!items.length) {
results.innerHTML = '<p>Keine Treffer gefunden.</p>'; results.innerHTML = '<p>Keine Treffer gefunden.</p>';
return; return;
} }
for (const item of items) { for (const item of items) {
let card; let card;
if (type === 'track') card = createTrackCard(item); 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) { async function openAlbumDialog(albumId, preselectedTrackNames = [], context = null) {
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;
sendContext = context; sendContext = context;
dialogTitle.textContent = album.name; dialogTitle.textContent = album.name;
dialogArtist.textContent = `Artist: ${album.artist}`; dialogArtist.textContent = `Artist: ${album.artist}`;
const preselected = new Set(preselectedTrackNames || []);
trackList.innerHTML = ''; trackList.innerHTML = '';
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; const isChecked = preselected.size > 0 ? preselected.has(track.name) : true;
row.innerHTML = ` row.innerHTML = `
<input type="checkbox" ${isChecked ? 'checked' : ''} data-track-name="${track.name.replace(/"/g, '&quot;')}" /> <input type="checkbox" ${isChecked ? 'checked' : ''} data-track-name="${track.name.replace(/"/g, '&quot;')}" data-exists="${Boolean(track.exists)}" />
<span>${track.trackNumber}. ${track.name}</span> <span>${track.trackNumber}. ${track.name}</span>
<small>(${formatDuration(track.durationMs)})</small> <small>(${formatDuration(track.durationMs)})</small>
${pill(Boolean(track.exists))}
`; `;
const input = row.querySelector('input');
input.addEventListener('change', updateDialogActionButton);
trackList.appendChild(row); trackList.appendChild(row);
} }
updateDialogActionButton();
dialog.showModal(); dialog.showModal();
setStatus('Album bereit. Tracks auswaehlen und senden.'); setStatus('Album bereit. Auswahl pruefen und senden.');
} catch (err) { } catch (err) {
setStatus(err.message, true); setStatus(err.message, true);
} }
@ -156,7 +201,6 @@ async function openAlbumDialog(albumId, preselectedTrackNames = [], context = nu
async function loadArtistAlbums(artistId, artistName) { async function loadArtistAlbums(artistId, artistName) {
setStatus(`Lade Alben von ${artistName}...`); setStatus(`Lade Alben von ${artistName}...`);
try { try {
const data = await fetchJson(`/api/spotify/artist/${artistId}/albums`); const data = await fetchJson(`/api/spotify/artist/${artistId}/albums`);
renderItems('album', data.albums || []); renderItems('album', data.albums || []);
@ -169,44 +213,69 @@ async function loadArtistAlbums(artistId, artistName) {
async function searchSpotify() { async function searchSpotify() {
const q = queryInput.value.trim(); const q = queryInput.value.trim();
const type = searchTypeSelect.value; const type = searchTypeSelect.value;
if (!q) return setStatus('Bitte Suchbegriff eingeben.', true);
if (!q) {
setStatus('Bitte Suchbegriff eingeben.', true);
return;
}
setStatus('Suche in Spotify...'); setStatus('Suche in Spotify...');
results.innerHTML = ''; results.innerHTML = '';
try { try {
const data = await fetchJson(`/api/spotify/search?q=${encodeURIComponent(q)}&type=${encodeURIComponent(type)}`); const data = await fetchJson(`/api/spotify/search?q=${encodeURIComponent(q)}&type=${encodeURIComponent(type)}`);
const items = data.items || []; renderItems(type, data.items || []);
renderItems(type, items); setStatus(`${(data.items || []).length} Treffer gefunden (${type}).`);
setStatus(`${items.length} Treffer gefunden (${type}).`);
} catch (err) { } catch (err) {
setStatus(err.message, true); 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) { async function sendToLidarr(event) {
event.preventDefault(); event.preventDefault();
if (!selectedAlbum) return setStatus('Kein Album ausgewaehlt.', true);
if (!selectedAlbum) { const rows = Array.from(trackList.querySelectorAll('input[type="checkbox"]'));
setStatus('Kein Album ausgewaehlt.', true); const trackStates = rows.map((input) => ({
return; 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')); setStatus('Sende Auswahl an Lidarr...');
const selectedTrackNames = checked.map((input) => input.dataset.trackName);
if (!selectedTrackNames.length) {
setStatus('Bitte mindestens einen Track auswaehlen.', true);
return;
}
setStatus('Sende Album an Lidarr...');
sendBtn.disabled = true; sendBtn.disabled = true;
try { try {
const data = await fetchJson('/api/lidarr/send-album', { const data = await fetchJson('/api/lidarr/send-album', {
method: 'POST', method: 'POST',
@ -215,16 +284,14 @@ async function sendToLidarr(event) {
albumName: sendContext?.albumName || selectedAlbum.name, albumName: sendContext?.albumName || selectedAlbum.name,
artistName: sendContext?.artistName || selectedAlbum.artist, artistName: sendContext?.artistName || selectedAlbum.artist,
selectedTrackNames, selectedTrackNames,
trackStates,
cleanupExtras: cleanupToggle.checked cleanupExtras: cleanupToggle.checked
}) })
}); });
const cleanupMsg = data.cleanup?.attempted setStatus(`Aktion gespeichert (+${data.trackActions.add} / -${data.trackActions.remove} / =${data.trackActions.keep}).`);
? ` Cleanup: ${data.cleanup.deleted} Dateien geloescht.`
: '';
setStatus(`Album erfolgreich an Lidarr uebergeben (ID ${data.albumId}).${cleanupMsg}`);
dialog.close(); dialog.close();
pollCommand(data.commandId, `${selectedAlbum.name}`);
} catch (err) { } catch (err) {
setStatus(`Fehler: ${err.message}`, true); setStatus(`Fehler: ${err.message}`, true);
} finally { } 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() { async function updateLibrary() {
setStatus('Starte Lidarr Bibliothek-Update...'); setStatus('Starte Lidarr Bibliothek-Update...');
updateLibraryBtn.disabled = true; updateLibraryBtn.disabled = true;
try { try {
const data = await fetchJson('/api/lidarr/update-library', { const data = await fetchJson('/api/lidarr/update-library', { method: 'POST' });
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
setStatus(`Bibliothek-Update gestartet (${data.commandNames.join(', ')}).`); setStatus(`Bibliothek-Update gestartet (${data.commandNames.join(', ')}).`);
} catch (err) { } catch (err) {
setStatus(`Fehler beim Bibliothek-Update: ${err.message}`, true); setStatus(`Fehler beim Bibliothek-Update: ${err.message}`, true);
@ -252,13 +328,8 @@ async function updateLibrary() {
async function updateFrontendFromGit() { async function updateFrontendFromGit() {
setStatus('Hole aktuelle Git-Daten...'); setStatus('Hole aktuelle Git-Daten...');
updateFrontendBtn.disabled = true; updateFrontendBtn.disabled = true;
try { try {
const data = await fetchJson('/api/system/update-frontend', { const data = await fetchJson('/api/system/update-frontend', { method: 'POST' });
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (data.updated) { if (data.updated) {
setStatus(`Frontend aktualisiert (${data.before} -> ${data.after}). Seite wird neu geladen...`); setStatus(`Frontend aktualisiert (${data.before} -> ${data.after}). Seite wird neu geladen...`);
setTimeout(() => window.location.reload(), 1200); setTimeout(() => window.location.reload(), 1200);
@ -272,12 +343,95 @@ async function updateFrontendFromGit() {
} }
} }
function renderPlaylist() {
results.innerHTML = '';
if (!playlistTracks.length) {
results.innerHTML = '<p>Keine Playlist-Tracks gefunden.</p>';
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); searchBtn.addEventListener('click', searchSpotify);
queryInput.addEventListener('keydown', (event) => { queryInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') searchSpotify();
searchSpotify();
}
}); });
sendBtn.addEventListener('click', sendToLidarr); sendBtn.addEventListener('click', sendToLidarr);
scanLibraryBtn.addEventListener('click', scanLibrary);
updateLibraryBtn.addEventListener('click', updateLibrary); updateLibraryBtn.addEventListener('click', updateLibrary);
updateFrontendBtn.addEventListener('click', updateFrontendFromGit); updateFrontendBtn.addEventListener('click', updateFrontendFromGit);
importPlaylistBtn.addEventListener('click', importPlaylist);
sendPlaylistBtn.addEventListener('click', sendPlaylistSelection);
renderJobs();

View file

@ -23,6 +23,7 @@
<span>Ueberfluessige Dateien nach Download loeschen</span> <span>Ueberfluessige Dateien nach Download loeschen</span>
</label> </label>
<div style="margin-top: 0.8rem;"> <div style="margin-top: 0.8rem;">
<button id="scanLibraryBtn" type="button">Bibliothek scannen</button>
<button id="updateLibraryBtn" type="button">Lidarr Bibliothek updaten</button> <button id="updateLibraryBtn" type="button">Lidarr Bibliothek updaten</button>
<button id="updateFrontendBtn" type="button">Frontend updaten</button> <button id="updateFrontendBtn" type="button">Frontend updaten</button>
</div> </div>
@ -31,6 +32,21 @@
</p> </p>
</section> </section>
<section class="panel">
<h2>Playlist importieren</h2>
<div class="search-row">
<select id="playlistSource">
<option value="spotify">Spotify Playlist</option>
<option value="youtube">YouTube Playlist</option>
</select>
<input id="playlistValue" placeholder="Playlist URL oder ID" />
<button id="importPlaylistBtn" type="button">Importieren</button>
</div>
<div id="playlistActions" style="display:none; margin-top:0.8rem;">
<button id="sendPlaylistBtn" type="button">Auswahl herunterladen</button>
</div>
</section>
<section class="panel search"> <section class="panel search">
<h2>Spotify suchen</h2> <h2>Spotify suchen</h2>
<div class="search-row"> <div class="search-row">
@ -50,6 +66,10 @@
</section> </section>
<section id="results" class="results"></section> <section id="results" class="results"></section>
<section id="jobs" class="panel">
<h2>Jobs</h2>
<div id="jobList"></div>
</section>
</main> </main>
<dialog id="albumDialog"> <dialog id="albumDialog">

View file

@ -135,6 +135,36 @@ button:hover {
font-size: 0.9rem; font-size: 0.9rem;
} }
.pill {
display: inline-block;
border-radius: 999px;
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
font-weight: 700;
}
.pill.exists {
background: #14532d;
color: #bbf7d0;
}
.pill.missing {
background: #1e3a8a;
color: #bfdbfe;
}
#jobList {
display: grid;
gap: 0.5rem;
}
.job-row {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem 0.7rem;
font-size: 0.9rem;
}
dialog { dialog {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;

View file

@ -19,6 +19,7 @@ TZ_VALUE="${TZ_VALUE:-Europe/Berlin}"
SPOTIFY_CLIENT_ID="${SPOTIFY_CLIENT_ID:-}" SPOTIFY_CLIENT_ID="${SPOTIFY_CLIENT_ID:-}"
SPOTIFY_CLIENT_SECRET="${SPOTIFY_CLIENT_SECRET:-}" SPOTIFY_CLIENT_SECRET="${SPOTIFY_CLIENT_SECRET:-}"
YOUTUBE_API_KEY="${YOUTUBE_API_KEY:-}"
LIDARR_URL="${LIDARR_URL:-http://lidarr:8686}" LIDARR_URL="${LIDARR_URL:-http://lidarr:8686}"
LIDARR_API_KEY="${LIDARR_API_KEY:-}" LIDARR_API_KEY="${LIDARR_API_KEY:-}"
LIDARR_ROOT_FOLDER="${LIDARR_ROOT_FOLDER:-/music}" LIDARR_ROOT_FOLDER="${LIDARR_ROOT_FOLDER:-/music}"
@ -41,6 +42,7 @@ MUSIC_PATH_ESCAPED="$(xml_escape "${MUSIC_PATH}")"
TZ_VALUE_ESCAPED="$(xml_escape "${TZ_VALUE}")" TZ_VALUE_ESCAPED="$(xml_escape "${TZ_VALUE}")"
SPOTIFY_CLIENT_ID_ESCAPED="$(xml_escape "${SPOTIFY_CLIENT_ID}")" SPOTIFY_CLIENT_ID_ESCAPED="$(xml_escape "${SPOTIFY_CLIENT_ID}")"
SPOTIFY_CLIENT_SECRET_ESCAPED="$(xml_escape "${SPOTIFY_CLIENT_SECRET}")" SPOTIFY_CLIENT_SECRET_ESCAPED="$(xml_escape "${SPOTIFY_CLIENT_SECRET}")"
YOUTUBE_API_KEY_ESCAPED="$(xml_escape "${YOUTUBE_API_KEY}")"
LIDARR_URL_ESCAPED="$(xml_escape "${LIDARR_URL}")" LIDARR_URL_ESCAPED="$(xml_escape "${LIDARR_URL}")"
LIDARR_API_KEY_ESCAPED="$(xml_escape "${LIDARR_API_KEY}")" LIDARR_API_KEY_ESCAPED="$(xml_escape "${LIDARR_API_KEY}")"
LIDARR_ROOT_FOLDER_ESCAPED="$(xml_escape "${LIDARR_ROOT_FOLDER}")" LIDARR_ROOT_FOLDER_ESCAPED="$(xml_escape "${LIDARR_ROOT_FOLDER}")"
@ -92,6 +94,7 @@ cat > "${TEMPLATE_PATH}" <<EOF
<Config Name="Timezone" Target="TZ" Default="Europe/Berlin" Mode="" Description="Container Variable: TZ" Type="Variable" Display="advanced" Required="false" Mask="false">${TZ_VALUE_ESCAPED}</Config> <Config Name="Timezone" Target="TZ" Default="Europe/Berlin" Mode="" Description="Container Variable: TZ" Type="Variable" Display="advanced" Required="false" Mask="false">${TZ_VALUE_ESCAPED}</Config>
<Config Name="Spotify Client ID" Target="SPOTIFY_CLIENT_ID" Default="" Mode="" Description="Container Variable: SPOTIFY_CLIENT_ID" Type="Variable" Display="always" Required="true" Mask="false">${SPOTIFY_CLIENT_ID_ESCAPED}</Config> <Config Name="Spotify Client ID" Target="SPOTIFY_CLIENT_ID" Default="" Mode="" Description="Container Variable: SPOTIFY_CLIENT_ID" Type="Variable" Display="always" Required="true" Mask="false">${SPOTIFY_CLIENT_ID_ESCAPED}</Config>
<Config Name="Spotify Client Secret" Target="SPOTIFY_CLIENT_SECRET" Default="" Mode="" Description="Container Variable: SPOTIFY_CLIENT_SECRET" Type="Variable" Display="always" Required="true" Mask="true">${SPOTIFY_CLIENT_SECRET_ESCAPED}</Config> <Config Name="Spotify Client Secret" Target="SPOTIFY_CLIENT_SECRET" Default="" Mode="" Description="Container Variable: SPOTIFY_CLIENT_SECRET" Type="Variable" Display="always" Required="true" Mask="true">${SPOTIFY_CLIENT_SECRET_ESCAPED}</Config>
<Config Name="YouTube API Key" Target="YOUTUBE_API_KEY" Default="" Mode="" Description="Container Variable: YOUTUBE_API_KEY (optional)" Type="Variable" Display="advanced" Required="false" Mask="true">${YOUTUBE_API_KEY_ESCAPED}</Config>
<Config Name="Lidarr URL" Target="LIDARR_URL" Default="http://lidarr:8686" Mode="" Description="Container Variable: LIDARR_URL" Type="Variable" Display="always" Required="true" Mask="false">${LIDARR_URL_ESCAPED}</Config> <Config Name="Lidarr URL" Target="LIDARR_URL" Default="http://lidarr:8686" Mode="" Description="Container Variable: LIDARR_URL" Type="Variable" Display="always" Required="true" Mask="false">${LIDARR_URL_ESCAPED}</Config>
<Config Name="Lidarr API Key" Target="LIDARR_API_KEY" Default="" Mode="" Description="Container Variable: LIDARR_API_KEY" Type="Variable" Display="always" Required="true" Mask="true">${LIDARR_API_KEY_ESCAPED}</Config> <Config Name="Lidarr API Key" Target="LIDARR_API_KEY" Default="" Mode="" Description="Container Variable: LIDARR_API_KEY" Type="Variable" Display="always" Required="true" Mask="true">${LIDARR_API_KEY_ESCAPED}</Config>
<Config Name="Lidarr Root Folder" Target="LIDARR_ROOT_FOLDER" Default="/music" Mode="" Description="Container Variable: LIDARR_ROOT_FOLDER" Type="Variable" Display="always" Required="true" Mask="false">${LIDARR_ROOT_FOLDER_ESCAPED}</Config> <Config Name="Lidarr Root Folder" Target="LIDARR_ROOT_FOLDER" Default="/music" Mode="" Description="Container Variable: LIDARR_ROOT_FOLDER" Type="Variable" Display="always" Required="true" Mask="false">${LIDARR_ROOT_FOLDER_ESCAPED}</Config>

355
server.js
View file

@ -4,6 +4,7 @@ const path = require('path');
const axios = require('axios'); const axios = require('axios');
const { execFile } = require('child_process'); const { execFile } = require('child_process');
const { promisify } = require('util'); const { promisify } = require('util');
const fs = require('fs');
const app = express(); const app = express();
const port = Number(process.env.PORT || 3000); const port = Number(process.env.PORT || 3000);
@ -16,6 +17,7 @@ const lidarrQualityProfileId = Number(process.env.LIDARR_QUALITY_PROFILE_ID || 1
const lidarrMetadataProfileId = Number(process.env.LIDARR_METADATA_PROFILE_ID || 1); const lidarrMetadataProfileId = Number(process.env.LIDARR_METADATA_PROFILE_ID || 1);
const spotifyClientId = process.env.SPOTIFY_CLIENT_ID || ''; const spotifyClientId = process.env.SPOTIFY_CLIENT_ID || '';
const spotifyClientSecret = process.env.SPOTIFY_CLIENT_SECRET || ''; const spotifyClientSecret = process.env.SPOTIFY_CLIENT_SECRET || '';
const youtubeApiKey = process.env.YOUTUBE_API_KEY || '';
app.use(express.json()); app.use(express.json());
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
@ -25,6 +27,13 @@ let spotifyTokenCache = {
expiresAt: 0 expiresAt: 0
}; };
let libraryIndexCache = {
byTitle: new Map(),
byAlbum: new Map(),
trackById: new Map(),
updatedAt: 0
};
function normalize(str) { function normalize(str) {
return String(str || '') return String(str || '')
.toLowerCase() .toLowerCase()
@ -52,7 +61,7 @@ async function runGit(args) {
} }
async function updateFrontendFromGit() { async function updateFrontendFromGit() {
if (!require('fs').existsSync(path.join(__dirname, '.git'))) { if (!fs.existsSync(path.join(__dirname, '.git'))) {
throw new Error('Kein Git-Repository im Container/Projektpfad gefunden.'); throw new Error('Kein Git-Repository im Container/Projektpfad gefunden.');
} }
@ -76,6 +85,99 @@ async function updateFrontendFromGit() {
return { before, after, branch, updated: before !== after }; return { before, after, branch, updated: before !== after };
} }
function normalizeTitle(title) {
return normalize(title)
.replace(/\b(feat|ft|with)\b.*/g, '')
.trim();
}
function buildTrackTitleKey(title) {
return normalizeTitle(title);
}
function buildAlbumKey(albumName, artistName) {
return `${normalize(albumName)}::${normalize(artistName)}`;
}
function hasFileForTrack(track) {
return Boolean(track?.hasFile || track?.trackFileId || track?.fileId);
}
function extractTrackName(track) {
return track?.title || track?.trackTitle || '';
}
function addTrackToLibraryIndex(track) {
const titleKey = buildTrackTitleKey(extractTrackName(track));
if (!titleKey) return;
if (!libraryIndexCache.byTitle.has(titleKey)) {
libraryIndexCache.byTitle.set(titleKey, []);
}
const entry = {
id: track.id,
albumId: track.albumId,
title: extractTrackName(track),
hasFile: hasFileForTrack(track)
};
libraryIndexCache.byTitle.get(titleKey).push(entry);
if (track.id) {
libraryIndexCache.trackById.set(track.id, entry);
}
}
async function rebuildLibraryIndex() {
const tracks = await lidarrRequest('get', '/api/v1/track');
const albums = await lidarrRequest('get', '/api/v1/album');
const byTitle = new Map();
const byAlbum = new Map();
const trackById = new Map();
const prev = libraryIndexCache;
libraryIndexCache = { byTitle, byAlbum, trackById, updatedAt: Date.now() };
for (const track of tracks || []) {
addTrackToLibraryIndex(track);
}
for (const album of albums || []) {
const k = buildAlbumKey(album.title || album.albumTitle || '', album.artistName || album.artist?.artistName || '');
if (k && !byAlbum.has(k)) byAlbum.set(k, album);
}
// Keep previous cache if rebuild produced nothing unexpectedly.
if ((tracks || []).length === 0 && prev.updatedAt > 0) {
libraryIndexCache = prev;
}
return {
trackCount: (tracks || []).length,
albumCount: (albums || []).length,
updatedAt: libraryIndexCache.updatedAt
};
}
async function ensureLibraryIndexFresh(maxAgeMs = 5 * 60 * 1000) {
const age = Date.now() - (libraryIndexCache.updatedAt || 0);
if (!libraryIndexCache.updatedAt || age > maxAgeMs) {
await rebuildLibraryIndex();
}
}
function getTrackLibraryState(trackName) {
const key = buildTrackTitleKey(trackName);
const matches = libraryIndexCache.byTitle.get(key) || [];
const existing = matches.some((m) => m.hasFile);
return {
exists: existing,
matchCount: matches.length
};
}
function getAlbumLibraryState(albumName, artistName) {
const key = buildAlbumKey(albumName, artistName);
return { exists: libraryIndexCache.byAlbum.has(key) };
}
async function lidarrRequest(method, endpoint, data, params) { async function lidarrRequest(method, endpoint, data, params) {
if (!hasLidarrConfig()) { if (!hasLidarrConfig()) {
throw new Error('Lidarr-Konfiguration unvollstaendig.'); throw new Error('Lidarr-Konfiguration unvollstaendig.');
@ -133,6 +235,112 @@ async function spotifyRequest(endpoint, params) {
return response.data; return response.data;
} }
function extractSpotifyPlaylistId(input) {
const value = String(input || '').trim();
if (!value) return null;
const match = value.match(/playlist\/([a-zA-Z0-9]+)|^([a-zA-Z0-9]{10,})$/);
return match?.[1] || match?.[2] || null;
}
function extractYouTubePlaylistId(input) {
const value = String(input || '').trim();
if (!value) return null;
const urlMatch = value.match(/[?&]list=([a-zA-Z0-9_-]+)/);
if (urlMatch?.[1]) return urlMatch[1];
if (/^[a-zA-Z0-9_-]{10,}$/.test(value)) return value;
return null;
}
async function fetchSpotifyPlaylistTracks(playlistId) {
const items = [];
let offset = 0;
while (true) {
const data = await spotifyRequest(`/playlists/${playlistId}/tracks`, {
limit: 100,
offset,
market: 'DE'
});
for (const row of data.items || []) {
const t = row.track;
if (!t || !t.album) continue;
items.push({
source: 'spotify',
id: t.id,
trackName: t.name,
artist: t.artists?.map((a) => a.name).join(', ') || 'Unbekannt',
albumId: t.album.id,
albumName: t.album.name,
albumArtist: t.album?.artists?.[0]?.name || t.artists?.[0]?.name || 'Unbekannt',
image: t.album.images?.[1]?.url || t.album.images?.[0]?.url || null
});
}
if (!data.next) break;
offset += data.limit || 100;
}
return items;
}
async function fetchYouTubePlaylistTracks(playlistId) {
if (!youtubeApiKey) {
throw new Error('YOUTUBE_API_KEY fehlt. YouTube-Import ist deaktiviert.');
}
const items = [];
let pageToken = '';
while (true) {
const response = await axios.get('https://www.googleapis.com/youtube/v3/playlistItems', {
params: {
key: youtubeApiKey,
part: 'snippet',
playlistId,
maxResults: 50,
pageToken
},
timeout: 20000
});
const rows = response.data.items || [];
for (const row of rows) {
const title = row.snippet?.title || '';
if (!title || title === 'Private video' || title === 'Deleted video') continue;
// Best-effort lookup against Spotify to map to album-based Lidarr flow.
try {
const spotify = await spotifyRequest('/search', {
q: title,
type: 'track',
limit: 1,
market: 'DE'
});
const t = spotify.tracks?.items?.[0];
if (!t?.album?.id) continue;
items.push({
source: 'youtube',
id: t.id,
trackName: t.name,
artist: t.artists?.map((a) => a.name).join(', ') || 'Unbekannt',
albumId: t.album.id,
albumName: t.album.name,
albumArtist: t.album?.artists?.[0]?.name || t.artists?.[0]?.name || 'Unbekannt',
image: t.album.images?.[1]?.url || t.album.images?.[0]?.url || null
});
} catch (_err) {
// Ignore unresolved tracks.
}
}
pageToken = response.data.nextPageToken || '';
if (!pageToken) break;
}
return items;
}
function pickAlbumCandidate(candidates, album, artistName) { function pickAlbumCandidate(candidates, album, artistName) {
if (!Array.isArray(candidates) || candidates.length === 0) { if (!Array.isArray(candidates) || candidates.length === 0) {
return null; return null;
@ -210,7 +418,7 @@ async function setTrackMonitoring(selectedTrackIds, allTrackIds) {
} }
async function triggerAlbumSearch(albumId) { async function triggerAlbumSearch(albumId) {
await lidarrRequest('post', '/api/v1/command', { return lidarrRequest('post', '/api/v1/command', {
name: 'MissingAlbumSearch', name: 'MissingAlbumSearch',
albumIds: [albumId] albumIds: [albumId]
}); });
@ -376,6 +584,7 @@ app.get('/api/spotify/search', async (req, res) => {
} }
try { try {
await ensureLibraryIndexFresh();
const data = await spotifyRequest('/search', { const data = await spotifyRequest('/search', {
q, q,
type, type,
@ -386,6 +595,7 @@ app.get('/api/spotify/search', async (req, res) => {
let items = []; let items = [];
if (type === 'album') { if (type === 'album') {
items = (data.albums?.items || []).map((album) => ({ items = (data.albums?.items || []).map((album) => ({
...getAlbumLibraryState(album.name, album.artists?.[0]?.name || ''),
id: album.id, id: album.id,
name: album.name, name: album.name,
artist: album.artists?.map((a) => a.name).join(', ') || 'Unbekannt', artist: album.artists?.map((a) => a.name).join(', ') || 'Unbekannt',
@ -396,6 +606,7 @@ app.get('/api/spotify/search', async (req, res) => {
} }
if (type === 'track') { if (type === 'track') {
items = (data.tracks?.items || []).map((track) => ({ items = (data.tracks?.items || []).map((track) => ({
...getTrackLibraryState(track.name),
id: track.id, id: track.id,
trackName: track.name, trackName: track.name,
artist: track.artists?.map((a) => a.name).join(', ') || 'Unbekannt', artist: track.artists?.map((a) => a.name).join(', ') || 'Unbekannt',
@ -432,6 +643,7 @@ app.get('/api/spotify/artist/:id/albums', async (req, res) => {
for (const album of data.items || []) { for (const album of data.items || []) {
if (unique.has(album.id)) continue; if (unique.has(album.id)) continue;
unique.set(album.id, { unique.set(album.id, {
...getAlbumLibraryState(album.name, album.artists?.[0]?.name || ''),
id: album.id, id: album.id,
name: album.name, name: album.name,
artist: album.artists?.map((a) => a.name).join(', ') || 'Unbekannt', artist: album.artists?.map((a) => a.name).join(', ') || 'Unbekannt',
@ -449,6 +661,7 @@ app.get('/api/spotify/artist/:id/albums', async (req, res) => {
app.get('/api/spotify/album/:id', async (req, res) => { app.get('/api/spotify/album/:id', async (req, res) => {
try { try {
await ensureLibraryIndexFresh();
const album = await spotifyRequest(`/albums/${req.params.id}`); const album = await spotifyRequest(`/albums/${req.params.id}`);
res.json({ res.json({
id: album.id, id: album.id,
@ -459,7 +672,8 @@ app.get('/api/spotify/album/:id', async (req, res) => {
id: track.id, id: track.id,
name: track.name, name: track.name,
durationMs: track.duration_ms, durationMs: track.duration_ms,
trackNumber: track.track_number trackNumber: track.track_number,
exists: getTrackLibraryState(track.name).exists
})) }))
}); });
} catch (err) { } catch (err) {
@ -467,48 +681,104 @@ app.get('/api/spotify/album/:id', async (req, res) => {
} }
}); });
app.post('/api/lidarr/send-album', async (req, res) => { app.post('/api/lidarr/library-scan', async (_req, res) => {
const { albumName, artistName, selectedTrackNames, cleanupExtras } = req.body || {}; try {
const result = await rebuildLibraryIndex();
res.json({ success: true, ...result });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
if (!albumName || !artistName) { app.post('/api/import/playlist', async (req, res) => {
return res.status(400).json({ error: 'albumName und artistName sind erforderlich.' }); const { source, value } = req.body || {};
try {
await ensureLibraryIndexFresh();
let tracks = [];
if (source === 'spotify') {
const playlistId = extractSpotifyPlaylistId(value);
if (!playlistId) return res.status(400).json({ error: 'Spotify Playlist-ID/URL ungueltig.' });
tracks = await fetchSpotifyPlaylistTracks(playlistId);
} else if (source === 'youtube') {
const playlistId = extractYouTubePlaylistId(value);
if (!playlistId) return res.status(400).json({ error: 'YouTube Playlist-ID/URL ungueltig.' });
tracks = await fetchYouTubePlaylistTracks(playlistId);
} else {
return res.status(400).json({ error: 'source muss spotify oder youtube sein.' });
}
const enriched = tracks.map((t) => ({
...t,
exists: getTrackLibraryState(t.trackName).exists
}));
res.json({ success: true, tracks: enriched });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
async function processSendAlbum(payload) {
const { albumName, artistName, selectedTrackNames, trackStates, cleanupExtras } = payload || {};
if (!albumName || !artistName) {
throw new Error('albumName und artistName sind erforderlich.');
} }
try {
const artist = await ensureArtist(artistName); const artist = await ensureArtist(artistName);
const album = await findLidarrAlbum(artist, albumName, artistName); const album = await findLidarrAlbum(artist, albumName, artistName);
if (!album || !album.id) { if (!album || !album.id) {
return res.status(404).json({ throw new Error('Album nicht in Lidarr gefunden. Bitte Artist in Lidarr aktualisieren und erneut versuchen.');
error: 'Album nicht in Lidarr gefunden. Bitte Artist in Lidarr aktualisieren und erneut versuchen.'
});
} }
const albumId = album.id; const albumId = album.id;
try { try {
await lidarrRequest('put', '/api/v1/album/monitor', { await lidarrRequest('put', '/api/v1/album/monitor', { albumIds: [albumId], monitored: true });
albumIds: [albumId], } catch (_err) {}
monitored: true
});
} catch (err) {
console.warn('Album-Monitoring konnte nicht gesetzt werden:', err.message);
}
let selectedTrackIds = []; let selectedTrackIds = [];
try { let deselectedExistingTrackIds = [];
const trackActions = { add: 0, remove: 0, keep: 0 };
const lidarrTracks = await lidarrRequest('get', '/api/v1/track', undefined, { albumId }); const lidarrTracks = await lidarrRequest('get', '/api/v1/track', undefined, { albumId });
const allTrackIds = Array.isArray(lidarrTracks) ? lidarrTracks.map((t) => t.id).filter(Boolean) : []; const allTrackIds = Array.isArray(lidarrTracks) ? lidarrTracks.map((t) => t.id).filter(Boolean) : [];
const selected = mapSelectedTracks(lidarrTracks || [], selectedTrackNames || []); const selected = mapSelectedTracks(lidarrTracks || [], selectedTrackNames || []);
selectedTrackIds = selected.map((t) => t.id).filter(Boolean); selectedTrackIds = selected.map((t) => t.id).filter(Boolean);
const stateMap = new Map((trackStates || []).map((s) => [normalize(s.name), s]));
deselectedExistingTrackIds = (lidarrTracks || [])
.filter((t) => {
const state = stateMap.get(normalize(extractTrackName(t)));
return state && state.exists && !state.selected && t.id;
})
.map((t) => t.id);
for (const s of trackStates || []) {
if (s.exists && s.selected) trackActions.keep += 1;
if (s.exists && !s.selected) trackActions.remove += 1;
if (!s.exists && s.selected) trackActions.add += 1;
}
if (selectedTrackIds.length > 0 && selectedTrackIds.length < allTrackIds.length) { if (selectedTrackIds.length > 0 && selectedTrackIds.length < allTrackIds.length) {
await setTrackMonitoring(selectedTrackIds, allTrackIds); await setTrackMonitoring(selectedTrackIds, allTrackIds);
} }
} catch (err) {
console.warn('Track-Verarbeitung fehlgeschlagen:', err.message);
}
await triggerAlbumSearch(albumId); const command = await triggerAlbumSearch(albumId);
const commandId = command?.id || null;
if (deselectedExistingTrackIds.length > 0) {
const keep = new Set(selectedTrackIds || []);
const files = await fetchTrackFiles(albumId);
for (const file of files) {
if (!file?.id || keep.has(file.trackId)) continue;
if (!deselectedExistingTrackIds.includes(file.trackId)) continue;
try {
await lidarrRequest('delete', `/api/v1/trackfile/${file.id}`);
} catch (_err) {
try {
await lidarrRequest('delete', `/api/v1/trackFile/${file.id}`);
} catch (_err2) {}
}
}
}
let cleanupResult = { deleted: 0, attempted: false }; let cleanupResult = { deleted: 0, attempted: false };
if (cleanupExtras && selectedTrackIds.length > 0) { if (cleanupExtras && selectedTrackIds.length > 0) {
@ -518,11 +788,38 @@ app.post('/api/lidarr/send-album', async (req, res) => {
}; };
} }
res.json({ return { success: true, albumId, commandId, trackActions, cleanup: cleanupResult };
success: true, }
albumId,
cleanup: cleanupResult app.post('/api/lidarr/send-album', async (req, res) => {
}); try {
const result = await processSendAlbum(req.body || {});
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/lidarr/send-batch', async (req, res) => {
const items = Array.isArray(req.body?.items) ? req.body.items : [];
if (!items.length) return res.status(400).json({ error: 'items fehlt oder ist leer.' });
const results = [];
for (const item of items) {
try {
const result = await processSendAlbum(item);
results.push({ ok: true, result });
} catch (err) {
results.push({ ok: false, error: err.message, item });
}
}
res.json({ success: true, results });
});
app.get('/api/lidarr/command/:id', async (req, res) => {
try {
const command = await lidarrRequest('get', `/api/v1/command/${req.params.id}`);
res.json(command);
} catch (err) { } catch (err) {
const status = err.response?.status || 500; const status = err.response?.status || 500;
const details = err.response?.data || err.message; const details = err.response?.data || err.message;