Initial commit: Spotify to Lidarr frontend with Docker and Unraid script
This commit is contained in:
commit
87a680326e
12 changed files with 996 additions and 0 deletions
169
public/app.js
Normal file
169
public/app.js
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
const queryInput = document.getElementById('query');
|
||||
const searchBtn = document.getElementById('searchBtn');
|
||||
const results = document.getElementById('results');
|
||||
const statusEl = document.getElementById('status');
|
||||
const dialog = document.getElementById('albumDialog');
|
||||
const dialogTitle = document.getElementById('dialogTitle');
|
||||
const dialogArtist = document.getElementById('dialogArtist');
|
||||
const trackList = document.getElementById('trackList');
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
const cleanupToggle = document.getElementById('cleanupToggle');
|
||||
|
||||
let selectedAlbum = null;
|
||||
let selectedTracks = [];
|
||||
|
||||
const CLEANUP_KEY = 'cleanupExtras';
|
||||
cleanupToggle.checked = localStorage.getItem(CLEANUP_KEY) === 'true';
|
||||
|
||||
cleanupToggle.addEventListener('change', () => {
|
||||
localStorage.setItem(CLEANUP_KEY, String(cleanupToggle.checked));
|
||||
});
|
||||
|
||||
function setStatus(text, isError = false) {
|
||||
statusEl.textContent = text;
|
||||
statusEl.style.color = isError ? 'var(--danger)' : 'var(--text)';
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
return `${min}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function renderAlbums(albums) {
|
||||
results.innerHTML = '';
|
||||
|
||||
if (!albums.length) {
|
||||
results.innerHTML = '<p>Keine Alben gefunden.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const album of albums) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'album-card';
|
||||
|
||||
card.innerHTML = `
|
||||
${album.image ? `<img class="album-cover" src="${album.image}" alt="${album.name}" />` : '<div class="album-cover"></div>'}
|
||||
<div class="album-body">
|
||||
<h3>${album.name}</h3>
|
||||
<p>${album.artist}</p>
|
||||
<p>${album.totalTracks} Tracks - ${album.releaseDate || 'unbekannt'}</p>
|
||||
<button type="button">Auswaehlen</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.querySelector('button').addEventListener('click', () => openAlbumDialog(album.id));
|
||||
results.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
async function openAlbumDialog(albumId) {
|
||||
setStatus('Lade Albumdetails...');
|
||||
|
||||
try {
|
||||
const album = await fetchJson(`/api/spotify/album/${albumId}`);
|
||||
selectedAlbum = album;
|
||||
selectedTracks = album.tracks.map((t) => t.name);
|
||||
|
||||
dialogTitle.textContent = album.name;
|
||||
dialogArtist.textContent = `Artist: ${album.artist}`;
|
||||
|
||||
trackList.innerHTML = '';
|
||||
for (const track of album.tracks) {
|
||||
const row = document.createElement('label');
|
||||
row.className = 'track-row';
|
||||
row.innerHTML = `
|
||||
<input type="checkbox" checked data-track-name="${track.name.replace(/"/g, '"')}" />
|
||||
<span>${track.trackNumber}. ${track.name}</span>
|
||||
<small>(${formatDuration(track.durationMs)})</small>
|
||||
`;
|
||||
trackList.appendChild(row);
|
||||
}
|
||||
|
||||
dialog.showModal();
|
||||
setStatus('Album bereit. Tracks auswaehlen und senden.');
|
||||
} catch (err) {
|
||||
setStatus(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function searchAlbums() {
|
||||
const q = queryInput.value.trim();
|
||||
if (!q) {
|
||||
setStatus('Bitte Suchbegriff eingeben.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Suche in Spotify...');
|
||||
results.innerHTML = '';
|
||||
|
||||
try {
|
||||
const data = await fetchJson(`/api/spotify/search?q=${encodeURIComponent(q)}`);
|
||||
renderAlbums(data.albums || []);
|
||||
setStatus(`${data.albums.length} Alben gefunden.`);
|
||||
} catch (err) {
|
||||
setStatus(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToLidarr(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedAlbum) {
|
||||
setStatus('Kein Album ausgewaehlt.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
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...');
|
||||
sendBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const data = await fetchJson('/api/lidarr/send-album', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
albumName: selectedAlbum.name,
|
||||
artistName: selectedAlbum.artist,
|
||||
selectedTrackNames,
|
||||
cleanupExtras: cleanupToggle.checked
|
||||
})
|
||||
});
|
||||
|
||||
const cleanupMsg = data.cleanup?.attempted
|
||||
? ` Cleanup: ${data.cleanup.deleted} Dateien geloescht.`
|
||||
: '';
|
||||
|
||||
setStatus(`Album erfolgreich an Lidarr uebergeben (ID ${data.albumId}).${cleanupMsg}`);
|
||||
dialog.close();
|
||||
} catch (err) {
|
||||
setStatus(`Fehler: ${err.message}`, true);
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
searchBtn.addEventListener('click', searchAlbums);
|
||||
queryInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
searchAlbums();
|
||||
}
|
||||
});
|
||||
sendBtn.addEventListener('click', sendToLidarr);
|
||||
60
public/index.html
Normal file
60
public/index.html
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lidarr Spotify Downloader</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="layout">
|
||||
<header class="hero">
|
||||
<h1>Spotify zu Lidarr</h1>
|
||||
<p>Album aus Spotify suchen, Tracks auswaehlen und direkt an Lidarr schicken.</p>
|
||||
</header>
|
||||
|
||||
<section class="panel settings">
|
||||
<h2>Einstellungen</h2>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="cleanupToggle" />
|
||||
<span>Ueberfluessige Dateien nach Download loeschen</span>
|
||||
</label>
|
||||
<p class="hint">
|
||||
Wenn nur einzelne Songs gewaehlt sind, versucht die App unnoetige Track-Dateien in Lidarr zu entfernen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="panel search">
|
||||
<h2>Album suchen</h2>
|
||||
<div class="search-row">
|
||||
<input id="query" placeholder="Album oder Artist eingeben" />
|
||||
<button id="searchBtn">Suchen</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="statusPanel">
|
||||
<strong>Status:</strong>
|
||||
<span id="status">Bereit.</span>
|
||||
</section>
|
||||
|
||||
<section id="results" class="results"></section>
|
||||
</main>
|
||||
|
||||
<dialog id="albumDialog">
|
||||
<form method="dialog" class="dialog-content">
|
||||
<h3 id="dialogTitle"></h3>
|
||||
<p id="dialogArtist"></p>
|
||||
<div id="trackList" class="track-list"></div>
|
||||
<div class="dialog-actions">
|
||||
<button value="cancel">Abbrechen</button>
|
||||
<button id="sendBtn" value="default">An Lidarr senden</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
171
public/styles.css
Normal file
171
public/styles.css
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
:root {
|
||||
--bg: #0f172a;
|
||||
--bg-soft: #1e293b;
|
||||
--card: #111827;
|
||||
--text: #e2e8f0;
|
||||
--muted: #94a3b8;
|
||||
--accent: #22d3ee;
|
||||
--accent-2: #f59e0b;
|
||||
--danger: #fb7185;
|
||||
--border: #334155;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at 10% 10%, #0ea5e9 0%, transparent 30%),
|
||||
radial-gradient(circle at 80% 0%, #f97316 0%, transparent 25%),
|
||||
var(--bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.layout {
|
||||
width: min(1100px, 92vw);
|
||||
margin: 2rem auto 3rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0 0 0.3rem;
|
||||
font-size: clamp(1.8rem, 2.4vw, 2.8rem);
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: color-mix(in srgb, var(--card) 88%, black);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.settings .hint {
|
||||
color: var(--muted);
|
||||
margin: 0.4rem 0 0;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
input,
|
||||
button {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.7rem 0.9rem;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input {
|
||||
background: #0b1220;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(120deg, var(--accent), var(--accent-2));
|
||||
color: #001018;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
filter: brightness(1.07);
|
||||
}
|
||||
|
||||
.results {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.album-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-soft);
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.album-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.album-body {
|
||||
padding: 0.8rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.album-body h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.album-body p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
dialog {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: #0b1220;
|
||||
color: var(--text);
|
||||
width: min(620px, 95vw);
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.track-list {
|
||||
max-height: 45vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.track-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
margin-top: 0.7rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.search-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue