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
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
10
.env.example
Normal file
10
.env.example
Normal file
|
|
@ -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
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
74
README.md
Normal file
74
README.md
Normal file
|
|
@ -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
|
||||||
|
```
|
||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
|
|
@ -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
|
||||||
14
package.json
Normal file
14
package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
scripts/run-unraid.sh
Executable file
89
scripts/run-unraid.sh
Executable file
|
|
@ -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}" <<EOF
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<Container version="2">
|
||||||
|
<Name>${CONTAINER_NAME}</Name>
|
||||||
|
<Repository>${IMAGE}</Repository>
|
||||||
|
<Registry>https://hub.docker.com/</Registry>
|
||||||
|
<Network>bridge</Network>
|
||||||
|
<MyIP/>
|
||||||
|
<Shell>sh</Shell>
|
||||||
|
<Privileged>false</Privileged>
|
||||||
|
<Support/>
|
||||||
|
<Project>https://github.com/</Project>
|
||||||
|
<Overview>Frontend fuer Spotify Album-Suche und Uebergabe an Lidarr.</Overview>
|
||||||
|
<Category>Downloader:</Category>
|
||||||
|
<WebUI>http://[IP]:[PORT:3000]/</WebUI>
|
||||||
|
<TemplateURL/>
|
||||||
|
<Icon>https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/svg/lidarr.svg</Icon>
|
||||||
|
<ExtraParams/>
|
||||||
|
<PostArgs/>
|
||||||
|
<CPUset/>
|
||||||
|
<DateInstalled>$(date +%s)</DateInstalled>
|
||||||
|
<DonateText/>
|
||||||
|
<DonateLink/>
|
||||||
|
<Description>Spotify Albumsuche, Track-Auswahl und Uebergabe an Lidarr.</Description>
|
||||||
|
<Networking>
|
||||||
|
<Mode>bridge</Mode>
|
||||||
|
<PublishPorts>true</PublishPorts>
|
||||||
|
</Networking>
|
||||||
|
<Data>
|
||||||
|
<Volume/>
|
||||||
|
<Port/>
|
||||||
|
<Variable/>
|
||||||
|
<Label/>
|
||||||
|
<Config Name="WebUI Port" Target="3000" Default="3000" Mode="tcp" Description="Port fuer das Frontend" Type="Port" Display="always" Required="true" Mask="false">${HOST_PORT}</Config>
|
||||||
|
<Config Name="Spotify Client ID" Target="SPOTIFY_CLIENT_ID" Default="" Mode="" Description="Spotify API Client ID" Type="Variable" Display="always" Required="true" Mask="false">${SPOTIFY_CLIENT_ID}</Config>
|
||||||
|
<Config Name="Spotify Client Secret" Target="SPOTIFY_CLIENT_SECRET" Default="" Mode="" Description="Spotify API Client Secret" Type="Variable" Display="always" Required="true" Mask="true">${SPOTIFY_CLIENT_SECRET}</Config>
|
||||||
|
<Config Name="Lidarr URL" Target="LIDARR_URL" Default="http://lidarr:8686" Mode="" Description="URL deines Lidarr Servers" Type="Variable" Display="always" Required="true" Mask="false">${LIDARR_URL}</Config>
|
||||||
|
<Config Name="Lidarr API Key" Target="LIDARR_API_KEY" Default="" Mode="" Description="Lidarr API Key" Type="Variable" Display="always" Required="true" Mask="true">${LIDARR_API_KEY}</Config>
|
||||||
|
<Config Name="Lidarr Root Folder" Target="LIDARR_ROOT_FOLDER" Default="/music" Mode="" Description="Muss in Lidarr existieren" Type="Variable" Display="always" Required="true" Mask="false">${LIDARR_ROOT_FOLDER}</Config>
|
||||||
|
<Config Name="Lidarr Quality Profile ID" Target="LIDARR_QUALITY_PROFILE_ID" Default="1" Mode="" Description="Quality Profile ID aus Lidarr" Type="Variable" Display="advanced" Required="true" Mask="false">${LIDARR_QUALITY_PROFILE_ID}</Config>
|
||||||
|
<Config Name="Lidarr Metadata Profile ID" Target="LIDARR_METADATA_PROFILE_ID" Default="1" Mode="" Description="Metadata Profile ID aus Lidarr" Type="Variable" Display="advanced" Required="true" Mask="false">${LIDARR_METADATA_PROFILE_ID}</Config>
|
||||||
|
</Data>
|
||||||
|
</Container>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "==> Fertig"
|
||||||
|
echo "Image: ${IMAGE}"
|
||||||
|
echo "Template: ${TEMPLATE_PATH}"
|
||||||
|
echo "Import in Unraid ueber: Docker -> Add Container -> Template -> ${TEMPLATE_NAME}"
|
||||||
371
server.js
Normal file
371
server.js
Normal file
|
|
@ -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}`);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue