diff --git a/.env.example b/.env.example index 2e00863..381a9a3 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ PORT=3000 SPOTIFY_CLIENT_ID=deine_spotify_client_id SPOTIFY_CLIENT_SECRET=dein_spotify_client_secret +YOUTUBE_API_KEY= LIDARR_URL=http://lidarr:8686 LIDARR_API_KEY=dein_lidarr_api_key diff --git a/README.md b/README.md index 8711ac2..f322e6b 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ Web-Frontend, um Alben aus Spotify zu suchen und an Lidarr zu uebergeben. - Einstellung im Frontend: ueberfluessige Dateien nach Download loeschen (optional) - Button fuer Lidarr Bibliothek-Update - Button fuer Frontend Git-Update (fetch/pull) +- Bibliotheksscan und \"Vorhanden/Fehlt\" Markierung +- Playlist-Import (Spotify, YouTube mit API-Key) +- Job-Statusanzeige fuer Downloads - Docker-ready ## Voraussetzungen diff --git a/docker-compose.yml b/docker-compose.yml index a2285a6..71b17e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - PORT=3000 - SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID} - SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET} + - YOUTUBE_API_KEY=${YOUTUBE_API_KEY} - LIDARR_URL=${LIDARR_URL} - LIDARR_API_KEY=${LIDARR_API_KEY} - LIDARR_ROOT_FOLDER=${LIDARR_ROOT_FOLDER} diff --git a/public/app.js b/public/app.js index f8a210e..9e2aa42 100644 --- a/public/app.js +++ b/public/app.js @@ -11,13 +11,21 @@ const sendBtn = document.getElementById('sendBtn'); const cleanupToggle = document.getElementById('cleanupToggle'); const updateLibraryBtn = document.getElementById('updateLibraryBtn'); const updateFrontendBtn = document.getElementById('updateFrontendBtn'); +const scanLibraryBtn = document.getElementById('scanLibraryBtn'); +const playlistSource = document.getElementById('playlistSource'); +const playlistValue = document.getElementById('playlistValue'); +const importPlaylistBtn = document.getElementById('importPlaylistBtn'); +const sendPlaylistBtn = document.getElementById('sendPlaylistBtn'); +const playlistActions = document.getElementById('playlistActions'); +const jobList = document.getElementById('jobList'); let selectedAlbum = null; let sendContext = null; +let playlistTracks = []; +let jobs = []; const CLEANUP_KEY = 'cleanupExtras'; cleanupToggle.checked = localStorage.getItem(CLEANUP_KEY) === 'true'; - cleanupToggle.addEventListener('change', () => { localStorage.setItem(CLEANUP_KEY, String(cleanupToggle.checked)); }); @@ -43,20 +51,23 @@ async function fetchJson(url, options = {}) { return data; } +function pill(exists) { + return `${exists ? 'Vorhanden' : 'Fehlt'}`; +} + function createAlbumCard(album, preselectedTrackNames = []) { const card = document.createElement('article'); card.className = 'album-card'; - card.innerHTML = ` ${album.image ? `${album.name}` : '
'}

${album.name}

${album.artist}

${album.totalTracks} Tracks - ${album.releaseDate || 'unbekannt'}

+

${pill(Boolean(album.exists))}

`; - card.querySelector('button').addEventListener('click', () => openAlbumDialog(album.id, preselectedTrackNames, { albumName: album.name, artistName: album.artist }) ); @@ -66,17 +77,16 @@ function createAlbumCard(album, preselectedTrackNames = []) { function createTrackCard(track) { const card = document.createElement('article'); card.className = 'album-card'; - card.innerHTML = ` ${track.image ? `${track.albumName}` : '
'}

${track.trackName}

${track.artist}

Album: ${track.albumName}

+

${pill(Boolean(track.exists))}

`; - card.querySelector('button').addEventListener('click', () => openAlbumDialog(track.albumId, [track.trackName], { albumName: track.albumName, @@ -89,29 +99,40 @@ function createTrackCard(track) { function createArtistCard(artist) { const card = document.createElement('article'); card.className = 'album-card'; - card.innerHTML = ` ${artist.image ? `${artist.name}` : '
'}

${artist.name}

${artist.followers || 0} Follower

-

Artist

`; - card.querySelector('button').addEventListener('click', () => loadArtistAlbums(artist.id, artist.name)); return card; } +function createPlaylistTrackCard(track, idx) { + const card = document.createElement('article'); + card.className = 'album-card'; + card.innerHTML = ` + ${track.image ? `${track.albumName}` : '
'} +
+

${track.trackName}

+

${track.artist}

+

Album: ${track.albumName}

+

${pill(Boolean(track.exists))}

+ +
+ `; + return card; +} + function renderItems(type, items) { results.innerHTML = ''; - if (!items.length) { results.innerHTML = '

Keine Treffer gefunden.

'; return; } - for (const item of items) { let card; if (type === 'track') card = createTrackCard(item); @@ -121,34 +142,58 @@ function renderItems(type, items) { } } +function updateDialogActionButton() { + const rows = Array.from(trackList.querySelectorAll('input[type="checkbox"]')); + const selected = rows.filter((x) => x.checked).map((x) => ({ + exists: x.dataset.exists === 'true' + })); + const add = selected.filter((x) => !x.exists).length; + const keep = selected.filter((x) => x.exists).length; + const remove = rows.filter((x) => !x.checked && x.dataset.exists === 'true').length; + + if (add > 0 && remove > 0) { + sendBtn.textContent = `Anwenden (+${add} / -${remove})`; + } else if (add > 0) { + sendBtn.textContent = `Hinzufuegen (${add})`; + } else if (remove > 0) { + sendBtn.textContent = `Entfernen (${remove})`; + } else if (keep > 0) { + sendBtn.textContent = `Behalten (${keep})`; + } else { + sendBtn.textContent = 'An Lidarr senden'; + } +} + async function openAlbumDialog(albumId, preselectedTrackNames = [], context = null) { setStatus('Lade Albumdetails...'); - try { const album = await fetchJson(`/api/spotify/album/${albumId}`); - const preselected = new Set(preselectedTrackNames || []); - selectedAlbum = album; sendContext = context; dialogTitle.textContent = album.name; dialogArtist.textContent = `Artist: ${album.artist}`; + const preselected = new Set(preselectedTrackNames || []); trackList.innerHTML = ''; for (const track of album.tracks) { const row = document.createElement('label'); row.className = 'track-row'; const isChecked = preselected.size > 0 ? preselected.has(track.name) : true; row.innerHTML = ` - + ${track.trackNumber}. ${track.name} (${formatDuration(track.durationMs)}) + ${pill(Boolean(track.exists))} `; + const input = row.querySelector('input'); + input.addEventListener('change', updateDialogActionButton); trackList.appendChild(row); } + updateDialogActionButton(); dialog.showModal(); - setStatus('Album bereit. Tracks auswaehlen und senden.'); + setStatus('Album bereit. Auswahl pruefen und senden.'); } catch (err) { setStatus(err.message, true); } @@ -156,7 +201,6 @@ async function openAlbumDialog(albumId, preselectedTrackNames = [], context = nu async function loadArtistAlbums(artistId, artistName) { setStatus(`Lade Alben von ${artistName}...`); - try { const data = await fetchJson(`/api/spotify/artist/${artistId}/albums`); renderItems('album', data.albums || []); @@ -169,44 +213,69 @@ async function loadArtistAlbums(artistId, artistName) { async function searchSpotify() { const q = queryInput.value.trim(); const type = searchTypeSelect.value; - - if (!q) { - setStatus('Bitte Suchbegriff eingeben.', true); - return; - } + if (!q) return setStatus('Bitte Suchbegriff eingeben.', true); setStatus('Suche in Spotify...'); results.innerHTML = ''; - try { const data = await fetchJson(`/api/spotify/search?q=${encodeURIComponent(q)}&type=${encodeURIComponent(type)}`); - const items = data.items || []; - renderItems(type, items); - setStatus(`${items.length} Treffer gefunden (${type}).`); + renderItems(type, data.items || []); + setStatus(`${(data.items || []).length} Treffer gefunden (${type}).`); } catch (err) { setStatus(err.message, true); } } +function addJob(job) { + jobs.unshift(job); + if (jobs.length > 30) jobs = jobs.slice(0, 30); + renderJobs(); +} + +function renderJobs() { + jobList.innerHTML = ''; + for (const job of jobs) { + const row = document.createElement('div'); + row.className = 'job-row'; + row.textContent = `${job.label}: ${job.status}`; + jobList.appendChild(row); + } +} + +async function pollCommand(commandId, label) { + if (!commandId) return; + const job = { label, status: 'queued', commandId }; + addJob(job); + + for (let i = 0; i < 60; i += 1) { + try { + const data = await fetchJson(`/api/lidarr/command/${commandId}`); + job.status = data.status || 'unknown'; + renderJobs(); + if (['completed', 'failed', 'aborted'].includes(String(data.status || '').toLowerCase())) return; + } catch (_err) { + job.status = 'status-unbekannt'; + renderJobs(); + return; + } + await new Promise((r) => setTimeout(r, 3000)); + } +} + async function sendToLidarr(event) { event.preventDefault(); + if (!selectedAlbum) return setStatus('Kein Album ausgewaehlt.', true); - if (!selectedAlbum) { - setStatus('Kein Album ausgewaehlt.', true); - return; - } + const rows = Array.from(trackList.querySelectorAll('input[type="checkbox"]')); + const trackStates = rows.map((input) => ({ + name: input.dataset.trackName, + selected: input.checked, + exists: input.dataset.exists === 'true' + })); + const selectedTrackNames = trackStates.filter((x) => x.selected).map((x) => x.name); - const checked = Array.from(trackList.querySelectorAll('input[type="checkbox"]:checked')); - const selectedTrackNames = checked.map((input) => input.dataset.trackName); - - if (!selectedTrackNames.length) { - setStatus('Bitte mindestens einen Track auswaehlen.', true); - return; - } - - setStatus('Sende Album an Lidarr...'); + setStatus('Sende Auswahl an Lidarr...'); sendBtn.disabled = true; - try { const data = await fetchJson('/api/lidarr/send-album', { method: 'POST', @@ -215,16 +284,14 @@ async function sendToLidarr(event) { albumName: sendContext?.albumName || selectedAlbum.name, artistName: sendContext?.artistName || selectedAlbum.artist, selectedTrackNames, + trackStates, cleanupExtras: cleanupToggle.checked }) }); - const cleanupMsg = data.cleanup?.attempted - ? ` Cleanup: ${data.cleanup.deleted} Dateien geloescht.` - : ''; - - setStatus(`Album erfolgreich an Lidarr uebergeben (ID ${data.albumId}).${cleanupMsg}`); + setStatus(`Aktion gespeichert (+${data.trackActions.add} / -${data.trackActions.remove} / =${data.trackActions.keep}).`); dialog.close(); + pollCommand(data.commandId, `${selectedAlbum.name}`); } catch (err) { setStatus(`Fehler: ${err.message}`, true); } finally { @@ -232,15 +299,24 @@ async function sendToLidarr(event) { } } +async function scanLibrary() { + scanLibraryBtn.disabled = true; + setStatus('Scanne Lidarr Bibliothek...'); + try { + const data = await fetchJson('/api/lidarr/library-scan', { method: 'POST' }); + setStatus(`Bibliothek gescannt (${data.trackCount} Tracks).`); + } catch (err) { + setStatus(`Scan fehlgeschlagen: ${err.message}`, true); + } finally { + scanLibraryBtn.disabled = false; + } +} + async function updateLibrary() { setStatus('Starte Lidarr Bibliothek-Update...'); updateLibraryBtn.disabled = true; - try { - const data = await fetchJson('/api/lidarr/update-library', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); + const data = await fetchJson('/api/lidarr/update-library', { method: 'POST' }); setStatus(`Bibliothek-Update gestartet (${data.commandNames.join(', ')}).`); } catch (err) { setStatus(`Fehler beim Bibliothek-Update: ${err.message}`, true); @@ -252,13 +328,8 @@ async function updateLibrary() { async function updateFrontendFromGit() { setStatus('Hole aktuelle Git-Daten...'); updateFrontendBtn.disabled = true; - try { - const data = await fetchJson('/api/system/update-frontend', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - + const data = await fetchJson('/api/system/update-frontend', { method: 'POST' }); if (data.updated) { setStatus(`Frontend aktualisiert (${data.before} -> ${data.after}). Seite wird neu geladen...`); setTimeout(() => window.location.reload(), 1200); @@ -272,12 +343,95 @@ async function updateFrontendFromGit() { } } +function renderPlaylist() { + results.innerHTML = ''; + if (!playlistTracks.length) { + results.innerHTML = '

Keine Playlist-Tracks gefunden.

'; + playlistActions.style.display = 'none'; + return; + } + playlistTracks.forEach((track, idx) => { + const card = createPlaylistTrackCard(track, idx); + const cb = card.querySelector('input[type="checkbox"]'); + cb.addEventListener('change', () => { + playlistTracks[idx].selected = cb.checked; + }); + results.appendChild(card); + }); + playlistActions.style.display = 'block'; +} + +async function importPlaylist() { + const source = playlistSource.value; + const value = playlistValue.value.trim(); + if (!value) return setStatus('Bitte Playlist URL oder ID eingeben.', true); + + importPlaylistBtn.disabled = true; + setStatus(`Importiere ${source}-Playlist...`); + try { + const data = await fetchJson('/api/import/playlist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source, value }) + }); + playlistTracks = (data.tracks || []).map((t) => ({ ...t, selected: true })); + renderPlaylist(); + setStatus(`${playlistTracks.length} Tracks importiert.`); + } catch (err) { + setStatus(`Playlist-Import fehlgeschlagen: ${err.message}`, true); + } finally { + importPlaylistBtn.disabled = false; + } +} + +async function sendPlaylistSelection() { + const selected = playlistTracks.filter((t) => t.selected); + if (!selected.length) return setStatus('Keine Playlist-Tracks ausgewaehlt.', true); + + const groups = new Map(); + for (const track of selected) { + const key = `${track.albumName}___${track.albumArtist}`; + if (!groups.has(key)) { + groups.set(key, { + albumName: track.albumName, + artistName: track.albumArtist, + selectedTrackNames: [], + trackStates: [] + }); + } + const g = groups.get(key); + g.selectedTrackNames.push(track.trackName); + g.trackStates.push({ name: track.trackName, selected: true, exists: Boolean(track.exists) }); + } + + setStatus(`Sende ${groups.size} Alben aus Playlist an Lidarr...`); + sendPlaylistBtn.disabled = true; + try { + for (const item of groups.values()) { + const data = await fetchJson('/api/lidarr/send-album', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(item) + }); + pollCommand(data.commandId, `${item.artistName} - ${item.albumName}`); + } + setStatus('Playlist-Auswahl wurde an Lidarr uebergeben.'); + } catch (err) { + setStatus(`Fehler bei Playlist-Download: ${err.message}`, true); + } finally { + sendPlaylistBtn.disabled = false; + } +} + searchBtn.addEventListener('click', searchSpotify); queryInput.addEventListener('keydown', (event) => { - if (event.key === 'Enter') { - searchSpotify(); - } + if (event.key === 'Enter') searchSpotify(); }); sendBtn.addEventListener('click', sendToLidarr); +scanLibraryBtn.addEventListener('click', scanLibrary); updateLibraryBtn.addEventListener('click', updateLibrary); updateFrontendBtn.addEventListener('click', updateFrontendFromGit); +importPlaylistBtn.addEventListener('click', importPlaylist); +sendPlaylistBtn.addEventListener('click', sendPlaylistSelection); + +renderJobs(); diff --git a/public/index.html b/public/index.html index 5dacad5..d052bb6 100644 --- a/public/index.html +++ b/public/index.html @@ -23,6 +23,7 @@ Ueberfluessige Dateien nach Download loeschen
+
@@ -31,6 +32,21 @@

+
+

Playlist importieren

+
+ + + +
+ +
+
+
+

Jobs

+
+
diff --git a/public/styles.css b/public/styles.css index f4c86c4..3df2a35 100644 --- a/public/styles.css +++ b/public/styles.css @@ -135,6 +135,36 @@ button:hover { font-size: 0.9rem; } +.pill { + display: inline-block; + border-radius: 999px; + padding: 0.2rem 0.5rem; + font-size: 0.75rem; + font-weight: 700; +} + +.pill.exists { + background: #14532d; + color: #bbf7d0; +} + +.pill.missing { + background: #1e3a8a; + color: #bfdbfe; +} + +#jobList { + display: grid; + gap: 0.5rem; +} + +.job-row { + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.5rem 0.7rem; + font-size: 0.9rem; +} + dialog { border: 1px solid var(--border); border-radius: 12px; diff --git a/scripts/run-unraid.sh b/scripts/run-unraid.sh index 934bfd5..24f16c9 100755 --- a/scripts/run-unraid.sh +++ b/scripts/run-unraid.sh @@ -19,6 +19,7 @@ TZ_VALUE="${TZ_VALUE:-Europe/Berlin}" SPOTIFY_CLIENT_ID="${SPOTIFY_CLIENT_ID:-}" SPOTIFY_CLIENT_SECRET="${SPOTIFY_CLIENT_SECRET:-}" +YOUTUBE_API_KEY="${YOUTUBE_API_KEY:-}" LIDARR_URL="${LIDARR_URL:-http://lidarr:8686}" LIDARR_API_KEY="${LIDARR_API_KEY:-}" LIDARR_ROOT_FOLDER="${LIDARR_ROOT_FOLDER:-/music}" @@ -41,6 +42,7 @@ MUSIC_PATH_ESCAPED="$(xml_escape "${MUSIC_PATH}")" TZ_VALUE_ESCAPED="$(xml_escape "${TZ_VALUE}")" SPOTIFY_CLIENT_ID_ESCAPED="$(xml_escape "${SPOTIFY_CLIENT_ID}")" SPOTIFY_CLIENT_SECRET_ESCAPED="$(xml_escape "${SPOTIFY_CLIENT_SECRET}")" +YOUTUBE_API_KEY_ESCAPED="$(xml_escape "${YOUTUBE_API_KEY}")" LIDARR_URL_ESCAPED="$(xml_escape "${LIDARR_URL}")" LIDARR_API_KEY_ESCAPED="$(xml_escape "${LIDARR_API_KEY}")" LIDARR_ROOT_FOLDER_ESCAPED="$(xml_escape "${LIDARR_ROOT_FOLDER}")" @@ -92,6 +94,7 @@ cat > "${TEMPLATE_PATH}" <${TZ_VALUE_ESCAPED} ${SPOTIFY_CLIENT_ID_ESCAPED} ${SPOTIFY_CLIENT_SECRET_ESCAPED} + ${YOUTUBE_API_KEY_ESCAPED} ${LIDARR_URL_ESCAPED} ${LIDARR_API_KEY_ESCAPED} ${LIDARR_ROOT_FOLDER_ESCAPED} diff --git a/server.js b/server.js index 3220f90..7972918 100644 --- a/server.js +++ b/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;