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
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