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

@ -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, '&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>
<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();

View file

@ -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">

View file

@ -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;