require('dotenv').config(); const express = require('express'); const path = require('path'); const axios = require('axios'); const { execFile } = require('child_process'); const { promisify } = require('util'); const fs = require('fs'); const app = express(); const port = Number(process.env.PORT || 3000); const execFileAsync = promisify(execFile); 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 || ''; const youtubeApiKey = process.env.YOUTUBE_API_KEY || ''; app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); let spotifyTokenCache = { token: null, expiresAt: 0 }; let libraryIndexCache = { byTitle: new Map(), byAlbum: new Map(), byTrackTriplet: new Map(), trackById: new Map(), updatedAt: 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 runGit(args) { const { stdout } = await execFileAsync('git', args, { cwd: __dirname, timeout: 20000 }); return String(stdout || '').trim(); } async function updateFrontendFromGit() { if (!fs.existsSync(path.join(__dirname, '.git'))) { throw new Error('Kein Git-Repository im Container/Projektpfad gefunden.'); } try { await runGit(['--version']); } catch (_err) { throw new Error('git ist nicht installiert.'); } const before = await runGit(['rev-parse', '--short', 'HEAD']); const branch = await runGit(['rev-parse', '--abbrev-ref', 'HEAD']); const dirty = await runGit(['status', '--porcelain']); if (dirty) { throw new Error('Lokale Aenderungen vorhanden. Update per Button ist blockiert.'); } await runGit(['fetch', '--prune', 'origin']); await runGit(['pull', '--ff-only', 'origin', branch]); const after = await runGit(['rev-parse', '--short', 'HEAD']); return { before, after, branch, updated: before !== after }; } function normalizeTitle(title) { return normalize(title) .replace(/\b(feat|ft|with)\b.*/g, '') .trim(); } function normalizeStrict(value) { return String(value || '') .toLowerCase() .replace(/\s+/g, ' ') .trim(); } function buildTrackTitleKey(title) { return normalizeTitle(title); } function buildAlbumKey(albumName, artistName) { return `${normalize(albumName)}::${normalize(artistName)}`; } function buildTrackTripletKey(trackName, albumName, artistName) { return `${normalizeStrict(trackName)}::${normalizeStrict(albumName)}::${normalizeStrict(artistName)}`; } function hasFileForTrack(track) { return Boolean(track?.hasFile || track?.trackFileId || track?.fileId); } function extractTrackName(track) { return track?.title || track?.trackTitle || ''; } function addTrackToLibraryIndex(track, albumMeta = null) { const titleKey = buildTrackTitleKey(extractTrackName(track)); if (!titleKey) return; if (!libraryIndexCache.byTitle.has(titleKey)) { libraryIndexCache.byTitle.set(titleKey, []); } const entry = { id: track.id, albumId: track.albumId, title: extractTrackName(track), albumName: albumMeta?.albumName || '', artistName: albumMeta?.artistName || '', hasFile: hasFileForTrack(track) }; libraryIndexCache.byTitle.get(titleKey).push(entry); if (track.id) { libraryIndexCache.trackById.set(track.id, entry); } const tripletKey = buildTrackTripletKey(entry.title, entry.albumName, entry.artistName); if (tripletKey) { if (!libraryIndexCache.byTrackTriplet.has(tripletKey)) { libraryIndexCache.byTrackTriplet.set(tripletKey, []); } libraryIndexCache.byTrackTriplet.get(tripletKey).push(entry); } } async function rebuildLibraryIndex() { const albums = await lidarrRequest('get', '/api/v1/album'); const byTitle = new Map(); const byAlbum = new Map(); const byTrackTriplet = new Map(); const trackById = new Map(); const prev = libraryIndexCache; libraryIndexCache = { byTitle, byAlbum, byTrackTriplet, trackById, updatedAt: Date.now() }; const albumMetaById = new Map(); for (const album of albums || []) { if (!album?.id) continue; albumMetaById.set(album.id, { albumName: album.title || album.albumTitle || '', artistName: album.artistName || album.artist?.artistName || '' }); } let trackCount = 0; for (const album of albums || []) { if (!album?.id) continue; let tracks = []; try { tracks = await lidarrRequest('get', '/api/v1/track', undefined, { albumId: album.id }); } catch (_err) { tracks = []; } for (const track of tracks || []) { addTrackToLibraryIndex(track, albumMetaById.get(album.id)); trackCount += 1; } } for (const album of albums || []) { const k = buildAlbumKey(album.title || album.albumTitle || '', album.artistName || album.artist?.artistName || ''); if (k && !byAlbum.has(k)) byAlbum.set(k, album); } // Keep previous cache if rebuild produced nothing unexpectedly. if (trackCount === 0 && prev.updatedAt > 0) { libraryIndexCache = prev; } return { trackCount, albumCount: (albums || []).length, updatedAt: libraryIndexCache.updatedAt }; } async function ensureLibraryIndexFresh(maxAgeMs = 5 * 60 * 1000) { const age = Date.now() - (libraryIndexCache.updatedAt || 0); if (!libraryIndexCache.updatedAt || age > maxAgeMs) { try { await rebuildLibraryIndex(); } catch (err) { console.warn('Library index rebuild fehlgeschlagen:', err.message); } } } function getTrackLibraryState(trackName, albumName, artistName) { const tripletKey = buildTrackTripletKey(trackName, albumName, artistName); const tripletMatches = libraryIndexCache.byTrackTriplet.get(tripletKey) || []; if (tripletMatches.length > 0) { const existing = tripletMatches.some((m) => m.hasFile); return { exists: existing, matchCount: tripletMatches.length }; } // Fallback only when no album/artist context exists. if (!albumName && !artistName) { const key = buildTrackTitleKey(trackName); const matches = libraryIndexCache.byTitle.get(key) || []; const existing = matches.some((m) => m.hasFile); return { exists: existing, matchCount: matches.length }; } // Strict mode with context: no full triplet match means "not existing". return { exists: false, matchCount: 0 }; } function getTrackLibraryStateLoose(trackName) { const key = buildTrackTitleKey(trackName); const matches = libraryIndexCache.byTitle.get(key) || []; const existing = matches.some((m) => m.hasFile); return { exists: existing, matchCount: matches.length }; } function getAlbumLibraryState(albumName, artistName) { const key = buildAlbumKey(albumName, artistName); return { exists: libraryIndexCache.byAlbum.has(key) }; } async function lidarrRequest(method, endpoint, data, params) { if (!hasLidarrConfig()) { throw new Error('Lidarr-Konfiguration unvollstaendig.'); } try { const response = await axios({ method, url: `${lidarrUrl}${endpoint}`, headers: lidarrHeaders(), data, params, timeout: 20000 }); return response.data; } catch (err) { const status = err.response?.status || 500; const body = err.response?.data; const detail = body?.message || body?.error || (Array.isArray(body) ? JSON.stringify(body) : typeof body === 'string' ? body : '') || err.message; const wrapped = new Error(`Lidarr API ${method.toUpperCase()} ${endpoint} -> ${detail}`); wrapped.status = status; throw wrapped; } } 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 extractSpotifyPlaylistId(input) { const value = String(input || '').trim(); if (!value) return null; const match = value.match(/playlist\/([a-zA-Z0-9]+)|^([a-zA-Z0-9]{10,})$/); return match?.[1] || match?.[2] || null; } function extractYouTubePlaylistId(input) { const value = String(input || '').trim(); if (!value) return null; const urlMatch = value.match(/[?&]list=([a-zA-Z0-9_-]+)/); if (urlMatch?.[1]) return urlMatch[1]; if (/^[a-zA-Z0-9_-]{10,}$/.test(value)) return value; return null; } async function fetchSpotifyPlaylistTracks(playlistId) { const items = []; let offset = 0; while (true) { const data = await spotifyRequest(`/playlists/${playlistId}/tracks`, { limit: 100, offset, market: 'DE' }); for (const row of data.items || []) { const t = row.track; if (!t || !t.album) continue; items.push({ source: 'spotify', id: t.id, trackName: t.name, artist: t.artists?.map((a) => a.name).join(', ') || 'Unbekannt', albumId: t.album.id, albumName: t.album.name, albumArtist: t.album?.artists?.[0]?.name || t.artists?.[0]?.name || 'Unbekannt', image: t.album.images?.[1]?.url || t.album.images?.[0]?.url || null }); } if (!data.next) break; offset += data.limit || 100; } return items; } async function fetchYouTubePlaylistTracks(playlistId) { if (!youtubeApiKey) { throw new Error('YOUTUBE_API_KEY fehlt. YouTube-Import ist deaktiviert.'); } const items = []; let pageToken = ''; while (true) { const response = await axios.get('https://www.googleapis.com/youtube/v3/playlistItems', { params: { key: youtubeApiKey, part: 'snippet', playlistId, maxResults: 50, pageToken }, timeout: 20000 }); const rows = response.data.items || []; for (const row of rows) { const title = row.snippet?.title || ''; if (!title || title === 'Private video' || title === 'Deleted video') continue; // Best-effort lookup against Spotify to map to album-based Lidarr flow. try { const spotify = await spotifyRequest('/search', { q: title, type: 'track', limit: 1, market: 'DE' }); const t = spotify.tracks?.items?.[0]; if (!t?.album?.id) continue; items.push({ source: 'youtube', id: t.id, trackName: t.name, artist: t.artists?.map((a) => a.name).join(', ') || 'Unbekannt', albumId: t.album.id, albumName: t.album.name, albumArtist: t.album?.artists?.[0]?.name || t.artists?.[0]?.name || 'Unbekannt', image: t.album.images?.[1]?.url || t.album.images?.[0]?.url || null }); } catch (_err) { // Ignore unresolved tracks. } } pageToken = response.data.nextPageToken || ''; if (!pageToken) break; } return items; } function pickAlbumCandidate(candidates, album, artistName) { if (!Array.isArray(candidates) || candidates.length === 0) { return null; } 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); }); } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } 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) { return lidarrRequest('post', '/api/v1/command', { name: 'MissingAlbumSearch', albumIds: [albumId] }); } async function triggerLibraryUpdate() { const started = []; try { await lidarrRequest('post', '/api/v1/command', { name: 'RescanFolders' }); started.push('RescanFolders'); } catch (err) { console.warn('RescanFolders konnte nicht gestartet werden:', err.message); } try { await lidarrRequest('post', '/api/v1/command', { name: 'RefreshArtist' }); started.push('RefreshArtist'); } catch (err) { console.warn('RefreshArtist konnte nicht gestartet werden:', err.message); } if (started.length === 0) { throw new Error('Kein Bibliothek-Update-Kommando konnte in Lidarr gestartet werden.'); } return started; } 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); } async function refreshArtistInLidarr(artistId) { const payloads = [ { name: 'RefreshArtist', artistId }, { name: 'RefreshArtist', artistIds: [artistId] } ]; for (const payload of payloads) { try { await lidarrRequest('post', '/api/v1/command', payload); return true; } catch (_err) { // Try next payload shape. } } return false; } async function fetchArtistAlbums(artistId) { try { const albums = await lidarrRequest('get', '/api/v1/album', undefined, { artistId }); return Array.isArray(albums) ? albums : []; } catch (_err) { return []; } } async function findLidarrAlbum(artist, albumName, fallbackArtistName) { const artistName = artist?.artistName || fallbackArtistName; // 1) Existing albums already known to Lidarr for this artist. const existing = await fetchArtistAlbums(artist.id); let candidate = pickAlbumCandidate(existing, albumName, artistName); if (candidate && candidate.id) return candidate; // 2) Force artist refresh and retry a few times. await refreshArtistInLidarr(artist.id); for (let i = 0; i < 4; i += 1) { await delay(1500); const refreshed = await fetchArtistAlbums(artist.id); candidate = pickAlbumCandidate(refreshed, albumName, artistName); if (candidate && candidate.id) return candidate; } // 3) Fallback lookup terms. const terms = [`${artistName} ${albumName}`, albumName]; for (const term of terms) { const lookup = await lidarrRequest('get', '/api/v1/album/lookup', undefined, { term }); candidate = pickAlbumCandidate(lookup, albumName, artistName); if (candidate && candidate.id) return candidate; } return null; } 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(); const type = String(req.query.type || 'album').trim(); const allowedTypes = new Set(['album', 'track', 'artist']); if (!q) { return res.status(400).json({ error: 'Suchbegriff fehlt.' }); } if (!allowedTypes.has(type)) { return res.status(400).json({ error: 'Ungueltiger Suchtyp. Erlaubt: album, track, artist.' }); } try { await ensureLibraryIndexFresh(); const data = await spotifyRequest('/search', { q, type, limit: 20, market: 'DE' }); let items = []; if (type === 'album') { items = (data.albums?.items || []).map((album) => ({ ...getAlbumLibraryState(album.name, album.artists?.[0]?.name || ''), id: album.id, name: album.name, artist: album.artists?.map((a) => a.name).join(', ') || 'Unbekannt', image: album.images?.[1]?.url || album.images?.[0]?.url || null, releaseDate: album.release_date, totalTracks: album.total_tracks })); } if (type === 'track') { items = (data.tracks?.items || []).map((track) => ({ ...getTrackLibraryState( track.name, track.album?.name || '', track.album?.artists?.[0]?.name || track.artists?.[0]?.name || '' ), id: track.id, trackName: track.name, artist: track.artists?.map((a) => a.name).join(', ') || 'Unbekannt', albumId: track.album?.id, albumName: track.album?.name || 'Unbekannt', albumArtist: track.album?.artists?.[0]?.name || track.artists?.[0]?.name || 'Unbekannt', image: track.album?.images?.[1]?.url || track.album?.images?.[0]?.url || null })); } if (type === 'artist') { items = (data.artists?.items || []).map((artist) => ({ id: artist.id, name: artist.name, followers: artist.followers?.total || 0, image: artist.images?.[1]?.url || artist.images?.[0]?.url || null })); } res.json({ items }); } catch (err) { res.status(500).json({ error: err.response?.data?.error?.message || err.message }); } }); app.get('/api/spotify/artist/:id/albums', async (req, res) => { try { const data = await spotifyRequest(`/artists/${req.params.id}/albums`, { include_groups: 'album,single', limit: 50, market: 'DE' }); const unique = new Map(); for (const album of data.items || []) { if (unique.has(album.id)) continue; unique.set(album.id, { ...getAlbumLibraryState(album.name, album.artists?.[0]?.name || ''), id: album.id, name: album.name, artist: album.artists?.map((a) => a.name).join(', ') || 'Unbekannt', image: album.images?.[1]?.url || album.images?.[0]?.url || null, releaseDate: album.release_date, totalTracks: album.total_tracks }); } res.json({ albums: Array.from(unique.values()) }); } catch (err) { res.status(500).json({ error: err.response?.data?.error?.message || err.message }); } }); app.get('/api/spotify/album/:id', async (req, res) => { try { await ensureLibraryIndexFresh(); const album = await spotifyRequest(`/albums/${req.params.id}`); res.json({ id: album.id, 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, exists: getTrackLibraryState( track.name, album.name, album.artists?.[0]?.name || 'Unbekannt' ).exists })) }); } catch (err) { res.status(500).json({ error: err.response?.data?.error?.message || err.message }); } }); app.post('/api/lidarr/library-scan', async (_req, res) => { try { const result = await rebuildLibraryIndex(); res.json({ success: true, ...result }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post('/api/import/playlist', async (req, res) => { const { source, value } = req.body || {}; try { await ensureLibraryIndexFresh(); let tracks = []; if (source === 'spotify') { const playlistId = extractSpotifyPlaylistId(value); if (!playlistId) return res.status(400).json({ error: 'Spotify Playlist-ID/URL ungueltig.' }); tracks = await fetchSpotifyPlaylistTracks(playlistId); } else if (source === 'youtube') { const playlistId = extractYouTubePlaylistId(value); if (!playlistId) return res.status(400).json({ error: 'YouTube Playlist-ID/URL ungueltig.' }); tracks = await fetchYouTubePlaylistTracks(playlistId); } else { return res.status(400).json({ error: 'source muss spotify oder youtube sein.' }); } const enriched = tracks.map((t) => ({ ...t, exists: getTrackLibraryState(t.trackName, t.albumName, t.albumArtist || t.artist).exists })); res.json({ success: true, tracks: enriched }); } catch (err) { res.status(500).json({ error: err.message }); } }); async function processSendAlbum(payload) { const { albumName, artistName, selectedTrackNames, trackStates, cleanupExtras } = payload || {}; if (!albumName || !artistName) { throw new Error('albumName und artistName sind erforderlich.'); } const artist = await ensureArtist(artistName); const album = await findLidarrAlbum(artist, albumName, artistName); if (!album || !album.id) { throw new Error('Album nicht in Lidarr gefunden. Bitte Artist in Lidarr aktualisieren und erneut versuchen.'); } const albumId = album.id; try { await lidarrRequest('put', '/api/v1/album/monitor', { albumIds: [albumId], monitored: true }); } catch (_err) {} let selectedTrackIds = []; let deselectedExistingTrackIds = []; const trackActions = { add: 0, remove: 0, keep: 0 }; const lidarrTracks = await lidarrRequest('get', '/api/v1/track', undefined, { albumId }); const allTrackIds = Array.isArray(lidarrTracks) ? lidarrTracks.map((t) => t.id).filter(Boolean) : []; const selected = mapSelectedTracks(lidarrTracks || [], selectedTrackNames || []); selectedTrackIds = selected.map((t) => t.id).filter(Boolean); const stateMap = new Map((trackStates || []).map((s) => [normalize(s.name), s])); deselectedExistingTrackIds = (lidarrTracks || []) .filter((t) => { const state = stateMap.get(normalize(extractTrackName(t))); return state && state.exists && !state.selected && t.id; }) .map((t) => t.id); for (const s of trackStates || []) { if (s.exists && s.selected) trackActions.keep += 1; if (s.exists && !s.selected) trackActions.remove += 1; if (!s.exists && s.selected) trackActions.add += 1; } if (selectedTrackIds.length > 0 && selectedTrackIds.length < allTrackIds.length) { await setTrackMonitoring(selectedTrackIds, allTrackIds); } const command = await triggerAlbumSearch(albumId); const commandId = command?.id || null; if (deselectedExistingTrackIds.length > 0) { const keep = new Set(selectedTrackIds || []); const files = await fetchTrackFiles(albumId); for (const file of files) { if (!file?.id || keep.has(file.trackId)) continue; if (!deselectedExistingTrackIds.includes(file.trackId)) continue; try { await lidarrRequest('delete', `/api/v1/trackfile/${file.id}`); } catch (_err) { try { await lidarrRequest('delete', `/api/v1/trackFile/${file.id}`); } catch (_err2) {} } } } let cleanupResult = { deleted: 0, attempted: false }; if (cleanupExtras && selectedTrackIds.length > 0) { cleanupResult = { ...(await cleanupUnselectedTrackFiles(albumId, selectedTrackIds)), attempted: true }; } return { success: true, albumId, commandId, trackActions, cleanup: cleanupResult }; } app.post('/api/lidarr/send-album', async (req, res) => { try { const result = await processSendAlbum(req.body || {}); res.json(result); } catch (err) { res.status(err.status || 500).json({ error: err.message }); } }); app.post('/api/lidarr/send-batch', async (req, res) => { const items = Array.isArray(req.body?.items) ? req.body.items : []; if (!items.length) return res.status(400).json({ error: 'items fehlt oder ist leer.' }); const results = []; for (const item of items) { try { const result = await processSendAlbum(item); results.push({ ok: true, result }); } catch (err) { results.push({ ok: false, status: err.status || 500, error: err.message, item }); } } res.json({ success: true, results }); }); app.get('/api/lidarr/command/:id', async (req, res) => { try { const command = await lidarrRequest('get', `/api/v1/command/${req.params.id}`); res.json(command); } catch (err) { const status = err.response?.status || 500; const details = err.response?.data || err.message; res.status(status).json({ error: typeof details === 'string' ? details : JSON.stringify(details) }); } }); app.post('/api/lidarr/update-library', async (_req, res) => { try { const commandNames = await triggerLibraryUpdate(); res.json({ success: true, commandNames }); } 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.post('/api/system/update-frontend', async (_req, res) => { try { const result = await updateFrontendFromGit(); res.json({ success: true, ...result }); } catch (err) { res.status(500).json({ error: err.message }); } }); app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.listen(port, () => { console.log(`Server running on http://localhost:${port}`); });