From 87a680326e102c257dfb5ba424d20b5e523db381 Mon Sep 17 00:00:00 2001 From: J0Z1L Date: Fri, 27 Feb 2026 23:07:13 +0100 Subject: [PATCH] Initial commit: Spotify to Lidarr frontend with Docker and Unraid script --- .dockerignore | 5 + .env.example | 10 ++ .gitignore | 3 + Dockerfile | 13 ++ README.md | 74 +++++++++ docker-compose.yml | 17 ++ package.json | 14 ++ public/app.js | 169 +++++++++++++++++++ public/index.html | 60 +++++++ public/styles.css | 171 +++++++++++++++++++ scripts/run-unraid.sh | 89 ++++++++++ server.js | 371 ++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 996 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css create mode 100755 scripts/run-unraid.sh create mode 100644 server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c6cdbfa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +npm-debug.log +.env +.git +.gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2e00863 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +PORT=3000 + +SPOTIFY_CLIENT_ID=deine_spotify_client_id +SPOTIFY_CLIENT_SECRET=dein_spotify_client_secret + +LIDARR_URL=http://lidarr:8686 +LIDARR_API_KEY=dein_lidarr_api_key +LIDARR_ROOT_FOLDER=/music +LIDARR_QUALITY_PROFILE_ID=1 +LIDARR_METADATA_PROFILE_ID=1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..047173c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +node_modules/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fdaf077 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY . . + +ENV NODE_ENV=production +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c2c627 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# Lidarr Spotify Frontend + +Web-Frontend, um Alben aus Spotify zu suchen und an Lidarr zu uebergeben. + +## Features + +- Albumsuche ueber Spotify API +- Track-Auswahl pro Album +- Uebergabe an Lidarr als Album-Download (MissingAlbumSearch) +- Einstellung im Frontend: ueberfluessige Dateien nach Download loeschen (optional) +- Docker-ready + +## Voraussetzungen + +- Spotify App mit `Client ID` und `Client Secret` +- Laufendes Lidarr mit API-Key +- In Lidarr existierender `Root Folder` (z. B. `/music`) + +## Konfiguration + +1. Beispiel kopieren: + +```bash +cp .env.example .env +``` + +2. Werte in `.env` setzen. + +## Start mit Docker Compose + +```bash +docker compose up --build -d +``` + +Danach erreichbar unter: [http://localhost:3000](http://localhost:3000) + +## Unraid: Build + Template automatisch aktualisieren/anlegen + +```bash +chmod +x scripts/run-unraid.sh +./scripts/run-unraid.sh +``` + +Optional mit eigenen Werten: + +```bash +SPOTIFY_CLIENT_ID=xxx \ +SPOTIFY_CLIENT_SECRET=yyy \ +LIDARR_URL=http://192.168.1.50:8686 \ +LIDARR_API_KEY=zzz \ +LIDARR_ROOT_FOLDER=/music \ +IMAGE_REPO=ghcr.io/dein-user/lidarr-spotify-frontend \ +IMAGE_TAG=latest \ +./scripts/run-unraid.sh +``` + +Das Skript: + +- baut das Docker-Image +- aktualisiert ein vorhandenes Unraid-Template unter `/boot/config/plugins/dockerMan/templates-user/` +- legt das Template neu an, falls es noch nicht existiert + +## Wichtige Hinweise + +- Lidarr arbeitet artist-zentriert. Die App versucht den Artist zuerst anzulegen und danach das Album in Lidarr zu finden. +- Track-Auswahl wird auf Lidarr-Tracknamen gemappt. Bei stark abweichenden Titeln kann die Zuordnung unvollstaendig sein. +- Die Cleanup-Option versucht unselektierte Track-Dateien ueber die Lidarr API zu loeschen, falls diese bereits vorhanden sind. + +## Lokaler Start ohne Docker + +```bash +npm install +npm start +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a2285a6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: "3.9" +services: + lidarr-spotify-frontend: + build: . + container_name: lidarr-spotify-frontend + ports: + - "3000:3000" + environment: + - PORT=3000 + - SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID} + - SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET} + - LIDARR_URL=${LIDARR_URL} + - LIDARR_API_KEY=${LIDARR_API_KEY} + - LIDARR_ROOT_FOLDER=${LIDARR_ROOT_FOLDER} + - LIDARR_QUALITY_PROFILE_ID=${LIDARR_QUALITY_PROFILE_ID:-1} + - LIDARR_METADATA_PROFILE_ID=${LIDARR_METADATA_PROFILE_ID:-1} + restart: unless-stopped diff --git a/package.json b/package.json new file mode 100644 index 0000000..cae07e9 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "lidarr-spotify-frontend", + "version": "1.0.0", + "description": "Frontend/API bridge for Spotify albums to Lidarr", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "axios": "^1.8.4", + "dotenv": "^16.4.7", + "express": "^4.21.2" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..f7f2574 --- /dev/null +++ b/public/app.js @@ -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 = '

Keine Alben gefunden.

'; + return; + } + + for (const album of albums) { + const card = document.createElement('article'); + card.className = 'album-card'; + + card.innerHTML = ` + ${album.image ? `${album.name}` : '
'} +
+

${album.name}

+

${album.artist}

+

${album.totalTracks} Tracks - ${album.releaseDate || 'unbekannt'}

+ +
+ `; + + 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 = ` + + ${track.trackNumber}. ${track.name} + (${formatDuration(track.durationMs)}) + `; + 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); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..a2e930b --- /dev/null +++ b/public/index.html @@ -0,0 +1,60 @@ + + + + + + Lidarr Spotify Downloader + + + + + + +
+
+

Spotify zu Lidarr

+

Album aus Spotify suchen, Tracks auswaehlen und direkt an Lidarr schicken.

+
+ +
+

Einstellungen

+ +

+ Wenn nur einzelne Songs gewaehlt sind, versucht die App unnoetige Track-Dateien in Lidarr zu entfernen. +

+
+ + + +
+ Status: + Bereit. +
+ +
+
+ + +
+

+

+
+
+ + +
+
+
+ + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..fd13e42 --- /dev/null +++ b/public/styles.css @@ -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; + } +} diff --git a/scripts/run-unraid.sh b/scripts/run-unraid.sh new file mode 100755 index 0000000..0990c8f --- /dev/null +++ b/scripts/run-unraid.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +TEMPLATE_DIR="${TEMPLATE_DIR:-/boot/config/plugins/dockerMan/templates-user}" +TEMPLATE_NAME="${TEMPLATE_NAME:-my-lidarr-spotify-frontend.xml}" +TEMPLATE_PATH="${TEMPLATE_DIR}/${TEMPLATE_NAME}" + +CONTAINER_NAME="${CONTAINER_NAME:-lidarr-spotify-frontend}" +IMAGE_REPO="${IMAGE_REPO:-local/lidarr-spotify-frontend}" +IMAGE_TAG="${IMAGE_TAG:-latest}" +IMAGE="${IMAGE_REPO}:${IMAGE_TAG}" +HOST_PORT="${HOST_PORT:-3000}" + +SPOTIFY_CLIENT_ID="${SPOTIFY_CLIENT_ID:-}" +SPOTIFY_CLIENT_SECRET="${SPOTIFY_CLIENT_SECRET:-}" +LIDARR_URL="${LIDARR_URL:-http://lidarr:8686}" +LIDARR_API_KEY="${LIDARR_API_KEY:-}" +LIDARR_ROOT_FOLDER="${LIDARR_ROOT_FOLDER:-/music}" +LIDARR_QUALITY_PROFILE_ID="${LIDARR_QUALITY_PROFILE_ID:-1}" +LIDARR_METADATA_PROFILE_ID="${LIDARR_METADATA_PROFILE_ID:-1}" + +if ! command -v docker >/dev/null 2>&1; then + echo "Fehler: docker wurde nicht gefunden." + exit 1 +fi + +echo "==> Build image ${IMAGE}" +docker build -t "${IMAGE}" "${PROJECT_ROOT}" + +mkdir -p "${TEMPLATE_DIR}" + +if [[ -f "${TEMPLATE_PATH}" ]]; then + echo "==> Aktualisiere vorhandenes Template: ${TEMPLATE_PATH}" +else + echo "==> Erstelle neues Template: ${TEMPLATE_PATH}" +fi + +cat > "${TEMPLATE_PATH}" < + + ${CONTAINER_NAME} + ${IMAGE} + https://hub.docker.com/ + bridge + + sh + false + + https://github.com/ + Frontend fuer Spotify Album-Suche und Uebergabe an Lidarr. + Downloader: + http://[IP]:[PORT:3000]/ + + https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/svg/lidarr.svg + + + + $(date +%s) + + + Spotify Albumsuche, Track-Auswahl und Uebergabe an Lidarr. + + bridge + true + + + + + + + +EOF + +echo "==> Fertig" +echo "Image: ${IMAGE}" +echo "Template: ${TEMPLATE_PATH}" +echo "Import in Unraid ueber: Docker -> Add Container -> Template -> ${TEMPLATE_NAME}" diff --git a/server.js b/server.js new file mode 100644 index 0000000..07d65b8 --- /dev/null +++ b/server.js @@ -0,0 +1,371 @@ +require('dotenv').config(); +const express = require('express'); +const path = require('path'); +const axios = require('axios'); + +const app = express(); +const port = Number(process.env.PORT || 3000); + +const lidarrUrl = (process.env.LIDARR_URL || '').replace(/\/$/, ''); +const lidarrApiKey = process.env.LIDARR_API_KEY || ''; +const lidarrRootFolder = process.env.LIDARR_ROOT_FOLDER || ''; +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 || ''; + +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'))); + +let spotifyTokenCache = { + token: null, + expiresAt: 0 +}; + +function normalize(str) { + return String(str || '') + .toLowerCase() + .replace(/\([^)]*\)/g, '') + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +function hasLidarrConfig() { + return Boolean(lidarrUrl && lidarrApiKey && lidarrRootFolder); +} + +function lidarrHeaders() { + return { + 'X-Api-Key': lidarrApiKey + }; +} + +async function lidarrRequest(method, endpoint, data, params) { + if (!hasLidarrConfig()) { + throw new Error('Lidarr-Konfiguration unvollstaendig.'); + } + + const response = await axios({ + method, + url: `${lidarrUrl}${endpoint}`, + headers: lidarrHeaders(), + data, + params, + timeout: 20000 + }); + + return response.data; +} + +async function getSpotifyToken() { + const now = Date.now(); + if (spotifyTokenCache.token && spotifyTokenCache.expiresAt > now + 10_000) { + return spotifyTokenCache.token; + } + + if (!spotifyClientId || !spotifyClientSecret) { + throw new Error('Spotify-Clientdaten fehlen.'); + } + + const credentials = Buffer.from(`${spotifyClientId}:${spotifyClientSecret}`).toString('base64'); + const body = new URLSearchParams({ grant_type: 'client_credentials' }); + + const response = await axios.post('https://accounts.spotify.com/api/token', body, { + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded' + }, + timeout: 15000 + }); + + spotifyTokenCache = { + token: response.data.access_token, + expiresAt: now + (response.data.expires_in || 3600) * 1000 + }; + + return spotifyTokenCache.token; +} + +async function spotifyRequest(endpoint, params) { + const token = await getSpotifyToken(); + const response = await axios.get(`https://api.spotify.com/v1${endpoint}`, { + headers: { Authorization: `Bearer ${token}` }, + params, + timeout: 20000 + }); + + return response.data; +} + +function pickAlbumCandidate(candidates, album, artistName) { + if (!Array.isArray(candidates) || candidates.length === 0) { + return null; + } + + const albumNorm = normalize(album); + const artistNorm = normalize(artistName); + + const scored = candidates.map((candidate) => { + const title = normalize(candidate.title || candidate.albumTitle || candidate.albumName); + const candidateArtist = normalize(candidate.artistName || candidate.artist?.artistName || ''); + + let score = 0; + if (title === albumNorm) score += 5; + if (title.includes(albumNorm) || albumNorm.includes(title)) score += 2; + if (candidateArtist === artistNorm) score += 5; + if (candidateArtist.includes(artistNorm) || artistNorm.includes(candidateArtist)) score += 2; + + return { candidate, score }; + }); + + scored.sort((a, b) => b.score - a.score); + return scored[0].candidate; +} + +function mapSelectedTracks(lidarrTracks, selectedTrackNames) { + const selectedNorm = new Set((selectedTrackNames || []).map((name) => normalize(name))); + + return lidarrTracks.filter((track) => { + const trackName = normalize(track.title || track.trackTitle || ''); + return selectedNorm.has(trackName); + }); +} + +async function fetchTrackFiles(albumId) { + const endpoints = ['/api/v1/trackfile', '/api/v1/trackFile']; + + for (const endpoint of endpoints) { + try { + const files = await lidarrRequest('get', endpoint, undefined, { albumId }); + if (Array.isArray(files)) return files; + } catch (err) { + if (err.response && err.response.status < 500) { + continue; + } + throw err; + } + } + + return []; +} + +async function setTrackMonitoring(selectedTrackIds, allTrackIds) { + if (!Array.isArray(allTrackIds) || allTrackIds.length === 0) return; + + try { + await lidarrRequest('put', '/api/v1/track/monitor', { + trackIds: allTrackIds, + monitored: false + }); + + if (Array.isArray(selectedTrackIds) && selectedTrackIds.length > 0) { + await lidarrRequest('put', '/api/v1/track/monitor', { + trackIds: selectedTrackIds, + monitored: true + }); + } + } catch (err) { + console.warn('Track monitoring konnte nicht gesetzt werden:', err.message); + } +} + +async function triggerAlbumSearch(albumId) { + await lidarrRequest('post', '/api/v1/command', { + name: 'MissingAlbumSearch', + albumIds: [albumId] + }); +} + +async function cleanupUnselectedTrackFiles(albumId, selectedTrackIds) { + const allFiles = await fetchTrackFiles(albumId); + if (!Array.isArray(allFiles) || allFiles.length === 0) { + return { deleted: 0 }; + } + + const keep = new Set(selectedTrackIds || []); + const toDelete = allFiles.filter((file) => !keep.has(file.trackId)); + + let deleted = 0; + for (const file of toDelete) { + if (!file.id) continue; + try { + await lidarrRequest('delete', `/api/v1/trackfile/${file.id}`); + deleted += 1; + } catch (err) { + try { + await lidarrRequest('delete', `/api/v1/trackFile/${file.id}`); + deleted += 1; + } catch (err2) { + console.warn(`Trackfile ${file.id} konnte nicht geloescht werden.`); + } + } + } + + return { deleted }; +} + +async function ensureArtist(artistName) { + const lookup = await lidarrRequest('get', '/api/v1/artist/lookup', undefined, { term: artistName }); + if (!Array.isArray(lookup) || lookup.length === 0) { + throw new Error(`Kein Artist in Lidarr gefunden: ${artistName}`); + } + + const exact = lookup.find((item) => normalize(item.artistName) === normalize(artistName)) || lookup[0]; + + const existingArtists = await lidarrRequest('get', '/api/v1/artist'); + const existing = Array.isArray(existingArtists) + ? existingArtists.find((a) => a.foreignArtistId === exact.foreignArtistId) + : null; + + if (existing) { + return existing; + } + + const payload = { + artistName: exact.artistName, + foreignArtistId: exact.foreignArtistId, + qualityProfileId: lidarrQualityProfileId, + metadataProfileId: lidarrMetadataProfileId, + monitored: true, + rootFolderPath: lidarrRootFolder, + addOptions: { + monitor: 'none', + searchForMissingAlbums: false + } + }; + + return lidarrRequest('post', '/api/v1/artist', payload); +} + +app.get('/api/health', (_req, res) => { + res.json({ + ok: true, + spotifyConfigured: Boolean(spotifyClientId && spotifyClientSecret), + lidarrConfigured: hasLidarrConfig() + }); +}); + +app.get('/api/spotify/search', async (req, res) => { + const q = String(req.query.q || '').trim(); + if (!q) { + return res.status(400).json({ error: 'Suchbegriff fehlt.' }); + } + + try { + const data = await spotifyRequest('/search', { + q, + type: 'album', + limit: 20, + market: 'DE' + }); + + const albums = (data.albums?.items || []).map((album) => ({ + id: album.id, + name: album.name, + artist: album.artists?.map((a) => a.name).join(', ') || 'Unbekannt', + image: album.images?.[1]?.url || album.images?.[0]?.url || null, + releaseDate: album.release_date, + totalTracks: album.total_tracks + })); + + res.json({ albums }); + } catch (err) { + res.status(500).json({ error: err.response?.data?.error?.message || err.message }); + } +}); + +app.get('/api/spotify/album/:id', async (req, res) => { + try { + const album = await spotifyRequest(`/albums/${req.params.id}`); + res.json({ + id: album.id, + name: album.name, + artist: album.artists?.[0]?.name || 'Unbekannt', + image: album.images?.[1]?.url || album.images?.[0]?.url || null, + tracks: (album.tracks?.items || []).map((track) => ({ + id: track.id, + name: track.name, + durationMs: track.duration_ms, + trackNumber: track.track_number + })) + }); + } catch (err) { + res.status(500).json({ error: err.response?.data?.error?.message || err.message }); + } +}); + +app.post('/api/lidarr/send-album', async (req, res) => { + const { albumName, artistName, selectedTrackNames, cleanupExtras } = req.body || {}; + + if (!albumName || !artistName) { + return res.status(400).json({ error: 'albumName und artistName sind erforderlich.' }); + } + + try { + await ensureArtist(artistName); + + const lookup = await lidarrRequest('get', '/api/v1/album/lookup', undefined, { + term: `${artistName} ${albumName}` + }); + + const album = pickAlbumCandidate(lookup, albumName, artistName); + if (!album || !album.id) { + return res.status(404).json({ + error: 'Album nicht in Lidarr-lookup gefunden. Pruefe Artist/Album oder Metadaten.' + }); + } + + 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); + } + + let selectedTrackIds = []; + try { + 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); + + if (selectedTrackIds.length > 0 && selectedTrackIds.length < allTrackIds.length) { + await setTrackMonitoring(selectedTrackIds, allTrackIds); + } + } catch (err) { + console.warn('Track-Verarbeitung fehlgeschlagen:', err.message); + } + + await triggerAlbumSearch(albumId); + + let cleanupResult = { deleted: 0, attempted: false }; + if (cleanupExtras && selectedTrackIds.length > 0) { + cleanupResult = { + ...(await cleanupUnselectedTrackFiles(albumId, selectedTrackIds)), + attempted: true + }; + } + + res.json({ + success: true, + albumId, + cleanup: cleanupResult + }); + } catch (err) { + const status = err.response?.status || 500; + const details = err.response?.data || err.message; + res.status(status).json({ error: typeof details === 'string' ? details : JSON.stringify(details) }); + } +}); + +app.get('*', (_req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +app.listen(port, () => { + console.log(`Server running on http://localhost:${port}`); +});