Add library presence scan, playlist import, responsive add/remove actions and job status UI
This commit is contained in:
parent
083dcbd992
commit
99568cb8e2
8 changed files with 613 additions and 104 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
270
public/app.js
270
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 `<span class="pill ${exists ? 'exists' : 'missing'}">${exists ? 'Vorhanden' : 'Fehlt'}</span>`;
|
||||
}
|
||||
|
||||
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>
|
||||
<p>${pill(Boolean(album.exists))}</p>
|
||||
<button type="button">Album auswaehlen</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 ? `<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>
|
||||
<button type="button">Album fuer Track auswaehlen</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 ? `<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 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) {
|
||||
results.innerHTML = '';
|
||||
|
||||
if (!items.length) {
|
||||
results.innerHTML = '<p>Keine Treffer gefunden.</p>';
|
||||
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 = `
|
||||
<input type="checkbox" ${isChecked ? 'checked' : ''} data-track-name="${track.name.replace(/"/g, '"')}" />
|
||||
<input type="checkbox" ${isChecked ? 'checked' : ''} data-track-name="${track.name.replace(/"/g, '"')}" data-exists="${Boolean(track.exists)}" />
|
||||
<span>${track.trackNumber}. ${track.name}</span>
|
||||
<small>(${formatDuration(track.durationMs)})</small>
|
||||
${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 = '<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);
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
<span>Ueberfluessige Dateien nach Download loeschen</span>
|
||||
</label>
|
||||
<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="updateFrontendBtn" type="button">Frontend updaten</button>
|
||||
</div>
|
||||
|
|
@ -31,6 +32,21 @@
|
|||
</p>
|
||||
</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">
|
||||
<h2>Spotify suchen</h2>
|
||||
<div class="search-row">
|
||||
|
|
@ -50,6 +66,10 @@
|
|||
</section>
|
||||
|
||||
<section id="results" class="results"></section>
|
||||
<section id="jobs" class="panel">
|
||||
<h2>Jobs</h2>
|
||||
<div id="jobList"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog id="albumDialog">
|
||||
|
|
|
|||
|
|
@ -135,6 +135,36 @@ button:hover {
|
|||
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 {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ TZ_VALUE="${TZ_VALUE:-Europe/Berlin}"
|
|||
|
||||
SPOTIFY_CLIENT_ID="${SPOTIFY_CLIENT_ID:-}"
|
||||
SPOTIFY_CLIENT_SECRET="${SPOTIFY_CLIENT_SECRET:-}"
|
||||
YOUTUBE_API_KEY="${YOUTUBE_API_KEY:-}"
|
||||
LIDARR_URL="${LIDARR_URL:-http://lidarr:8686}"
|
||||
LIDARR_API_KEY="${LIDARR_API_KEY:-}"
|
||||
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}")"
|
||||
SPOTIFY_CLIENT_ID_ESCAPED="$(xml_escape "${SPOTIFY_CLIENT_ID}")"
|
||||
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_API_KEY_ESCAPED="$(xml_escape "${LIDARR_API_KEY}")"
|
||||
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="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="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 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>
|
||||
|
|
|
|||
353
server.js
353
server.js
|
|
@ -4,6 +4,7 @@ const path = require('path');
|
|||
const axios = require('axios');
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const fs = require('fs');
|
||||
|
||||
const app = express();
|
||||
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 spotifyClientId = process.env.SPOTIFY_CLIENT_ID || '';
|
||||
const spotifyClientSecret = process.env.SPOTIFY_CLIENT_SECRET || '';
|
||||
const youtubeApiKey = process.env.YOUTUBE_API_KEY || '';
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
|
@ -25,6 +27,13 @@ let spotifyTokenCache = {
|
|||
expiresAt: 0
|
||||
};
|
||||
|
||||
let libraryIndexCache = {
|
||||
byTitle: new Map(),
|
||||
byAlbum: new Map(),
|
||||
trackById: new Map(),
|
||||
updatedAt: 0
|
||||
};
|
||||
|
||||
function normalize(str) {
|
||||
return String(str || '')
|
||||
.toLowerCase()
|
||||
|
|
@ -52,7 +61,7 @@ async function runGit(args) {
|
|||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +85,99 @@ async function updateFrontendFromGit() {
|
|||
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) {
|
||||
if (!hasLidarrConfig()) {
|
||||
throw new Error('Lidarr-Konfiguration unvollstaendig.');
|
||||
|
|
@ -133,6 +235,112 @@ async function spotifyRequest(endpoint, params) {
|
|||
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) {
|
||||
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||||
return null;
|
||||
|
|
@ -210,7 +418,7 @@ async function setTrackMonitoring(selectedTrackIds, allTrackIds) {
|
|||
}
|
||||
|
||||
async function triggerAlbumSearch(albumId) {
|
||||
await lidarrRequest('post', '/api/v1/command', {
|
||||
return lidarrRequest('post', '/api/v1/command', {
|
||||
name: 'MissingAlbumSearch',
|
||||
albumIds: [albumId]
|
||||
});
|
||||
|
|
@ -376,6 +584,7 @@ app.get('/api/spotify/search', async (req, res) => {
|
|||
}
|
||||
|
||||
try {
|
||||
await ensureLibraryIndexFresh();
|
||||
const data = await spotifyRequest('/search', {
|
||||
q,
|
||||
type,
|
||||
|
|
@ -386,6 +595,7 @@ app.get('/api/spotify/search', async (req, res) => {
|
|||
let items = [];
|
||||
if (type === 'album') {
|
||||
items = (data.albums?.items || []).map((album) => ({
|
||||
...getAlbumLibraryState(album.name, album.artists?.[0]?.name || ''),
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
artist: album.artists?.map((a) => a.name).join(', ') || 'Unbekannt',
|
||||
|
|
@ -396,6 +606,7 @@ app.get('/api/spotify/search', async (req, res) => {
|
|||
}
|
||||
if (type === 'track') {
|
||||
items = (data.tracks?.items || []).map((track) => ({
|
||||
...getTrackLibraryState(track.name),
|
||||
id: track.id,
|
||||
trackName: track.name,
|
||||
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 || []) {
|
||||
if (unique.has(album.id)) continue;
|
||||
unique.set(album.id, {
|
||||
...getAlbumLibraryState(album.name, album.artists?.[0]?.name || ''),
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
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) => {
|
||||
try {
|
||||
await ensureLibraryIndexFresh();
|
||||
const album = await spotifyRequest(`/albums/${req.params.id}`);
|
||||
res.json({
|
||||
id: album.id,
|
||||
|
|
@ -459,7 +672,8 @@ app.get('/api/spotify/album/:id', async (req, res) => {
|
|||
id: track.id,
|
||||
name: track.name,
|
||||
durationMs: track.duration_ms,
|
||||
trackNumber: track.track_number
|
||||
trackNumber: track.track_number,
|
||||
exists: getTrackLibraryState(track.name).exists
|
||||
}))
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
@ -467,48 +681,104 @@ app.get('/api/spotify/album/:id', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
app.post('/api/lidarr/send-album', async (req, res) => {
|
||||
const { albumName, artistName, selectedTrackNames, cleanupExtras } = req.body || {};
|
||||
app.post('/api/lidarr/library-scan', async (_req, res) => {
|
||||
try {
|
||||
const result = await rebuildLibraryIndex();
|
||||
res.json({ success: true, ...result });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
if (!albumName || !artistName) {
|
||||
return res.status(400).json({ error: 'albumName und artistName sind erforderlich.' });
|
||||
app.post('/api/import/playlist', async (req, res) => {
|
||||
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 album = await findLidarrAlbum(artist, albumName, artistName);
|
||||
if (!album || !album.id) {
|
||||
return res.status(404).json({
|
||||
error: 'Album nicht in Lidarr gefunden. Bitte Artist in Lidarr aktualisieren und erneut versuchen.'
|
||||
});
|
||||
throw new Error('Album nicht in Lidarr gefunden. Bitte Artist in Lidarr aktualisieren und erneut versuchen.');
|
||||
}
|
||||
|
||||
const albumId = album.id;
|
||||
|
||||
try {
|
||||
await lidarrRequest('put', '/api/v1/album/monitor', {
|
||||
albumIds: [albumId],
|
||||
monitored: true
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Album-Monitoring konnte nicht gesetzt werden:', err.message);
|
||||
}
|
||||
await lidarrRequest('put', '/api/v1/album/monitor', { albumIds: [albumId], monitored: true });
|
||||
} catch (_err) {}
|
||||
|
||||
let selectedTrackIds = [];
|
||||
try {
|
||||
let deselectedExistingTrackIds = [];
|
||||
const trackActions = { add: 0, remove: 0, keep: 0 };
|
||||
const lidarrTracks = await lidarrRequest('get', '/api/v1/track', undefined, { albumId });
|
||||
const allTrackIds = Array.isArray(lidarrTracks) ? lidarrTracks.map((t) => t.id).filter(Boolean) : [];
|
||||
const selected = mapSelectedTracks(lidarrTracks || [], selectedTrackNames || []);
|
||||
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) {
|
||||
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 };
|
||||
if (cleanupExtras && selectedTrackIds.length > 0) {
|
||||
|
|
@ -518,11 +788,38 @@ app.post('/api/lidarr/send-album', async (req, res) => {
|
|||
};
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
albumId,
|
||||
cleanup: cleanupResult
|
||||
return { success: true, albumId, commandId, trackActions, 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) {
|
||||
const status = err.response?.status || 500;
|
||||
const details = err.response?.data || err.message;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue