Add library presence scan, playlist import, responsive add/remove actions and job status UI
This commit is contained in:
parent
083dcbd992
commit
99568cb8e2
8 changed files with 613 additions and 104 deletions
389
server.js
389
server.js
|
|
@ -4,6 +4,7 @@ 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);
|
||||
|
|
@ -16,6 +17,7 @@ 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')));
|
||||
|
|
@ -25,6 +27,13 @@ let spotifyTokenCache = {
|
|||
expiresAt: 0
|
||||
};
|
||||
|
||||
let libraryIndexCache = {
|
||||
byTitle: new Map(),
|
||||
byAlbum: new Map(),
|
||||
trackById: new Map(),
|
||||
updatedAt: 0
|
||||
};
|
||||
|
||||
function normalize(str) {
|
||||
return String(str || '')
|
||||
.toLowerCase()
|
||||
|
|
@ -52,7 +61,7 @@ async function runGit(args) {
|
|||
}
|
||||
|
||||
async function updateFrontendFromGit() {
|
||||
if (!require('fs').existsSync(path.join(__dirname, '.git'))) {
|
||||
if (!fs.existsSync(path.join(__dirname, '.git'))) {
|
||||
throw new Error('Kein Git-Repository im Container/Projektpfad gefunden.');
|
||||
}
|
||||
|
||||
|
|
@ -76,6 +85,99 @@ async function updateFrontendFromGit() {
|
|||
return { before, after, branch, updated: before !== after };
|
||||
}
|
||||
|
||||
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) };
|
||||
}
|
||||
|
||||
async function lidarrRequest(method, endpoint, data, params) {
|
||||
if (!hasLidarrConfig()) {
|
||||
throw new Error('Lidarr-Konfiguration unvollstaendig.');
|
||||
|
|
@ -133,6 +235,112 @@ async function spotifyRequest(endpoint, params) {
|
|||
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;
|
||||
|
|
@ -210,7 +418,7 @@ async function setTrackMonitoring(selectedTrackIds, allTrackIds) {
|
|||
}
|
||||
|
||||
async function triggerAlbumSearch(albumId) {
|
||||
await lidarrRequest('post', '/api/v1/command', {
|
||||
return lidarrRequest('post', '/api/v1/command', {
|
||||
name: 'MissingAlbumSearch',
|
||||
albumIds: [albumId]
|
||||
});
|
||||
|
|
@ -376,6 +584,7 @@ app.get('/api/spotify/search', async (req, res) => {
|
|||
}
|
||||
|
||||
try {
|
||||
await ensureLibraryIndexFresh();
|
||||
const data = await spotifyRequest('/search', {
|
||||
q,
|
||||
type,
|
||||
|
|
@ -386,6 +595,7 @@ app.get('/api/spotify/search', async (req, res) => {
|
|||
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',
|
||||
|
|
@ -396,6 +606,7 @@ app.get('/api/spotify/search', async (req, res) => {
|
|||
}
|
||||
if (type === 'track') {
|
||||
items = (data.tracks?.items || []).map((track) => ({
|
||||
...getTrackLibraryState(track.name),
|
||||
id: track.id,
|
||||
trackName: track.name,
|
||||
artist: track.artists?.map((a) => a.name).join(', ') || 'Unbekannt',
|
||||
|
|
@ -432,6 +643,7 @@ app.get('/api/spotify/artist/:id/albums', async (req, res) => {
|
|||
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',
|
||||
|
|
@ -449,6 +661,7 @@ app.get('/api/spotify/artist/:id/albums', async (req, res) => {
|
|||
|
||||
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,
|
||||
|
|
@ -459,7 +672,8 @@ app.get('/api/spotify/album/:id', async (req, res) => {
|
|||
id: track.id,
|
||||
name: track.name,
|
||||
durationMs: track.duration_ms,
|
||||
trackNumber: track.track_number
|
||||
trackNumber: track.track_number,
|
||||
exists: getTrackLibraryState(track.name).exists
|
||||
}))
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
@ -467,62 +681,145 @@ app.get('/api/spotify/album/:id', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
app.post('/api/lidarr/send-album', async (req, res) => {
|
||||
const { albumName, artistName, selectedTrackNames, cleanupExtras } = req.body || {};
|
||||
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).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) {
|
||||
return res.status(400).json({ error: 'albumName und artistName sind erforderlich.' });
|
||||
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 {
|
||||
const artist = await ensureArtist(artistName);
|
||||
const album = await findLidarrAlbum(artist, albumName, artistName);
|
||||
if (!album || !album.id) {
|
||||
return res.status(404).json({
|
||||
error: 'Album nicht in Lidarr gefunden. Bitte Artist in Lidarr aktualisieren und erneut versuchen.'
|
||||
});
|
||||
}
|
||||
await lidarrRequest('put', '/api/v1/album/monitor', { albumIds: [albumId], monitored: true });
|
||||
} catch (_err) {}
|
||||
|
||||
const albumId = album.id;
|
||||
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);
|
||||
|
||||
try {
|
||||
await lidarrRequest('put', '/api/v1/album/monitor', {
|
||||
albumIds: [albumId],
|
||||
monitored: true
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Album-Monitoring konnte nicht gesetzt werden:', err.message);
|
||||
}
|
||||
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);
|
||||
|
||||
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);
|
||||
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);
|
||||
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(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) {
|
||||
console.warn('Track-Verarbeitung fehlgeschlagen:', err.message);
|
||||
results.push({ ok: false, error: err.message, item });
|
||||
}
|
||||
}
|
||||
res.json({ success: true, results });
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue