2026-02-27 23:07:13 +01:00
|
|
|
require('dotenv').config();
|
|
|
|
|
const express = require('express');
|
|
|
|
|
const path = require('path');
|
|
|
|
|
const axios = require('axios');
|
2026-02-28 00:12:10 +01:00
|
|
|
const { execFile } = require('child_process');
|
|
|
|
|
const { promisify } = require('util');
|
2026-02-28 00:33:16 +01:00
|
|
|
const fs = require('fs');
|
2026-02-27 23:07:13 +01:00
|
|
|
|
|
|
|
|
const app = express();
|
|
|
|
|
const port = Number(process.env.PORT || 3000);
|
2026-02-28 00:12:10 +01:00
|
|
|
const execFileAsync = promisify(execFile);
|
2026-02-27 23:07:13 +01:00
|
|
|
|
|
|
|
|
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 || '';
|
2026-02-28 00:33:16 +01:00
|
|
|
const youtubeApiKey = process.env.YOUTUBE_API_KEY || '';
|
2026-02-27 23:07:13 +01:00
|
|
|
|
|
|
|
|
app.use(express.json());
|
|
|
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
|
|
|
|
|
|
let spotifyTokenCache = {
|
|
|
|
|
token: null,
|
|
|
|
|
expiresAt: 0
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-28 00:33:16 +01:00
|
|
|
let libraryIndexCache = {
|
|
|
|
|
byTitle: new Map(),
|
|
|
|
|
byAlbum: new Map(),
|
|
|
|
|
trackById: new Map(),
|
|
|
|
|
updatedAt: 0
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-27 23:07:13 +01:00
|
|
|
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
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 00:12:10 +01:00
|
|
|
async function runGit(args) {
|
|
|
|
|
const { stdout } = await execFileAsync('git', args, {
|
|
|
|
|
cwd: __dirname,
|
|
|
|
|
timeout: 20000
|
|
|
|
|
});
|
|
|
|
|
return String(stdout || '').trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function updateFrontendFromGit() {
|
2026-02-28 00:33:16 +01:00
|
|
|
if (!fs.existsSync(path.join(__dirname, '.git'))) {
|
2026-02-28 00:12:10 +01:00
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 00:33:16 +01:00
|
|
|
function normalizeTitle(title) {
|
|
|
|
|
return normalize(title)
|
|
|
|
|
.replace(/\b(feat|ft|with)\b.*/g, '')
|
|
|
|
|
.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildTrackTitleKey(title) {
|
|
|
|
|
return normalizeTitle(title);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildAlbumKey(albumName, artistName) {
|
|
|
|
|
return `${normalize(albumName)}::${normalize(artistName)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasFileForTrack(track) {
|
|
|
|
|
return Boolean(track?.hasFile || track?.trackFileId || track?.fileId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractTrackName(track) {
|
|
|
|
|
return track?.title || track?.trackTitle || '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addTrackToLibraryIndex(track) {
|
|
|
|
|
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),
|
|
|
|
|
hasFile: hasFileForTrack(track)
|
|
|
|
|
};
|
|
|
|
|
libraryIndexCache.byTitle.get(titleKey).push(entry);
|
|
|
|
|
if (track.id) {
|
|
|
|
|
libraryIndexCache.trackById.set(track.id, entry);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function rebuildLibraryIndex() {
|
|
|
|
|
const tracks = await lidarrRequest('get', '/api/v1/track');
|
|
|
|
|
const albums = await lidarrRequest('get', '/api/v1/album');
|
|
|
|
|
const byTitle = new Map();
|
|
|
|
|
const byAlbum = new Map();
|
|
|
|
|
const trackById = new Map();
|
|
|
|
|
|
|
|
|
|
const prev = libraryIndexCache;
|
|
|
|
|
libraryIndexCache = { byTitle, byAlbum, trackById, updatedAt: Date.now() };
|
|
|
|
|
|
|
|
|
|
for (const track of tracks || []) {
|
|
|
|
|
addTrackToLibraryIndex(track);
|
|
|
|
|
}
|
|
|
|
|
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 ((tracks || []).length === 0 && prev.updatedAt > 0) {
|
|
|
|
|
libraryIndexCache = prev;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
trackCount: (tracks || []).length,
|
|
|
|
|
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) {
|
|
|
|
|
await rebuildLibraryIndex();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getTrackLibraryState(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) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 23:07:13 +01:00
|
|
|
async function lidarrRequest(method, endpoint, data, params) {
|
|
|
|
|
if (!hasLidarrConfig()) {
|
|
|
|
|
throw new Error('Lidarr-Konfiguration unvollstaendig.');
|
|
|
|
|
}
|
2026-02-28 00:41:40 +01:00
|
|
|
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;
|
|
|
|
|
}
|
2026-02-27 23:07:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 00:33:16 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 23:07:13 +01:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 00:17:55 +01:00
|
|
|
function delay(ms) {
|
|
|
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 23:07:13 +01:00
|
|
|
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) {
|
2026-02-28 00:33:16 +01:00
|
|
|
return lidarrRequest('post', '/api/v1/command', {
|
2026-02-27 23:07:13 +01:00
|
|
|
name: 'MissingAlbumSearch',
|
|
|
|
|
albumIds: [albumId]
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 00:09:47 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 23:07:13 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 00:17:55 +01:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 23:07:13 +01:00
|
|
|
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();
|
2026-02-28 00:07:18 +01:00
|
|
|
const type = String(req.query.type || 'album').trim();
|
|
|
|
|
const allowedTypes = new Set(['album', 'track', 'artist']);
|
|
|
|
|
|
2026-02-27 23:07:13 +01:00
|
|
|
if (!q) {
|
|
|
|
|
return res.status(400).json({ error: 'Suchbegriff fehlt.' });
|
|
|
|
|
}
|
2026-02-28 00:07:18 +01:00
|
|
|
if (!allowedTypes.has(type)) {
|
|
|
|
|
return res.status(400).json({ error: 'Ungueltiger Suchtyp. Erlaubt: album, track, artist.' });
|
|
|
|
|
}
|
2026-02-27 23:07:13 +01:00
|
|
|
|
|
|
|
|
try {
|
2026-02-28 00:33:16 +01:00
|
|
|
await ensureLibraryIndexFresh();
|
2026-02-27 23:07:13 +01:00
|
|
|
const data = await spotifyRequest('/search', {
|
|
|
|
|
q,
|
2026-02-28 00:07:18 +01:00
|
|
|
type,
|
2026-02-27 23:07:13 +01:00
|
|
|
limit: 20,
|
|
|
|
|
market: 'DE'
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-28 00:07:18 +01:00
|
|
|
let items = [];
|
|
|
|
|
if (type === 'album') {
|
|
|
|
|
items = (data.albums?.items || []).map((album) => ({
|
2026-02-28 00:33:16 +01:00
|
|
|
...getAlbumLibraryState(album.name, album.artists?.[0]?.name || ''),
|
2026-02-28 00:07:18 +01:00
|
|
|
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) => ({
|
2026-02-28 00:33:16 +01:00
|
|
|
...getTrackLibraryState(track.name),
|
2026-02-28 00:07:18 +01:00
|
|
|
id: track.id,
|
|
|
|
|
trackName: track.name,
|
|
|
|
|
artist: track.artists?.map((a) => a.name).join(', ') || 'Unbekannt',
|
|
|
|
|
albumId: track.album?.id,
|
|
|
|
|
albumName: track.album?.name || 'Unbekannt',
|
2026-02-28 00:18:59 +01:00
|
|
|
albumArtist: track.album?.artists?.[0]?.name || track.artists?.[0]?.name || 'Unbekannt',
|
2026-02-28 00:07:18 +01:00
|
|
|
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, {
|
2026-02-28 00:33:16 +01:00
|
|
|
...getAlbumLibraryState(album.name, album.artists?.[0]?.name || ''),
|
2026-02-28 00:07:18 +01:00
|
|
|
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
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-27 23:07:13 +01:00
|
|
|
|
2026-02-28 00:07:18 +01:00
|
|
|
res.json({ albums: Array.from(unique.values()) });
|
2026-02-27 23:07:13 +01:00
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ error: err.response?.data?.error?.message || err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.get('/api/spotify/album/:id', async (req, res) => {
|
|
|
|
|
try {
|
2026-02-28 00:33:16 +01:00
|
|
|
await ensureLibraryIndexFresh();
|
2026-02-27 23:07:13 +01:00
|
|
|
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,
|
2026-02-28 00:33:16 +01:00
|
|
|
trackNumber: track.track_number,
|
|
|
|
|
exists: getTrackLibraryState(track.name).exists
|
2026-02-27 23:07:13 +01:00
|
|
|
}))
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ error: err.response?.data?.error?.message || err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-28 00:33:16 +01:00
|
|
|
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 });
|
2026-02-27 23:07:13 +01:00
|
|
|
}
|
2026-02-28 00:33:16 +01:00
|
|
|
});
|
2026-02-27 23:07:13 +01:00
|
|
|
|
2026-02-28 00:33:16 +01:00
|
|
|
app.post('/api/import/playlist', async (req, res) => {
|
|
|
|
|
const { source, value } = req.body || {};
|
2026-02-27 23:07:13 +01:00
|
|
|
try {
|
2026-02-28 00:33:16 +01:00
|
|
|
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.' });
|
2026-02-27 23:07:13 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-28 00:33:16 +01:00
|
|
|
const enriched = tracks.map((t) => ({
|
|
|
|
|
...t,
|
|
|
|
|
exists: getTrackLibraryState(t.trackName).exists
|
|
|
|
|
}));
|
2026-02-27 23:07:13 +01:00
|
|
|
|
2026-02-28 00:33:16 +01:00
|
|
|
res.json({ success: true, tracks: enriched });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-02-27 23:07:13 +01:00
|
|
|
|
2026-02-28 00:33:16 +01:00
|
|
|
async function processSendAlbum(payload) {
|
|
|
|
|
const { albumName, artistName, selectedTrackNames, trackStates, cleanupExtras } = payload || {};
|
|
|
|
|
if (!albumName || !artistName) {
|
|
|
|
|
throw new Error('albumName und artistName sind erforderlich.');
|
|
|
|
|
}
|
2026-02-27 23:07:13 +01:00
|
|
|
|
2026-02-28 00:33:16 +01:00
|
|
|
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) {}
|
2026-02-27 23:07:13 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-28 00:33:16 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-02-28 00:41:40 +01:00
|
|
|
res.status(err.status || 500).json({ error: err.message });
|
2026-02-28 00:33:16 +01:00
|
|
|
}
|
|
|
|
|
});
|
2026-02-27 23:07:13 +01:00
|
|
|
|
2026-02-28 00:33:16 +01:00
|
|
|
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.' });
|
2026-02-27 23:07:13 +01:00
|
|
|
|
2026-02-28 00:33:16 +01:00
|
|
|
const results = [];
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
try {
|
|
|
|
|
const result = await processSendAlbum(item);
|
|
|
|
|
results.push({ ok: true, result });
|
|
|
|
|
} catch (err) {
|
2026-02-28 00:41:40 +01:00
|
|
|
results.push({ ok: false, status: err.status || 500, error: err.message, item });
|
2026-02-27 23:07:13 +01:00
|
|
|
}
|
2026-02-28 00:33:16 +01:00
|
|
|
}
|
|
|
|
|
res.json({ success: true, results });
|
|
|
|
|
});
|
2026-02-27 23:07:13 +01:00
|
|
|
|
2026-02-28 00:33:16 +01:00
|
|
|
app.get('/api/lidarr/command/:id', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const command = await lidarrRequest('get', `/api/v1/command/${req.params.id}`);
|
|
|
|
|
res.json(command);
|
2026-02-27 23:07:13 +01:00
|
|
|
} 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) });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-28 00:09:47 +01:00
|
|
|
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) });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-28 00:12:10 +01:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-27 23:07:13 +01:00
|
|
|
app.get('*', (_req, res) => {
|
|
|
|
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.listen(port, () => {
|
|
|
|
|
console.log(`Server running on http://localhost:${port}`);
|
|
|
|
|
});
|