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 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 || ''; 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 runGit(args) { const { stdout } = await execFileAsync('git', args, { cwd: __dirname, timeout: 20000 }); return String(stdout || '').trim(); } async function updateFrontendFromGit() { if (!require('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 }; } 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 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); } 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 { const data = await spotifyRequest('/search', { q, type, limit: 20, market: 'DE' }); let items = []; if (type === 'album') { items = (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 })); } if (type === 'track') { items = (data.tracks?.items || []).map((track) => ({ id: track.id, trackName: track.name, artist: track.artists?.map((a) => a.name).join(', ') || 'Unbekannt', albumId: track.album?.id, albumName: track.album?.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, { 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 { 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.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}`); });