From b021df31b45f12222aab285f7629d8a81fad4b0b Mon Sep 17 00:00:00 2001 From: J0Z1L Date: Sat, 28 Feb 2026 11:33:59 +0100 Subject: [PATCH] Use strict album-title-artist existence matching and add exists/missing result filter --- public/app.js | 37 ++++++++++++++++++--- public/index.html | 5 +++ server.js | 83 +++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 114 insertions(+), 11 deletions(-) diff --git a/public/app.js b/public/app.js index 9e2aa42..9e0b2d5 100644 --- a/public/app.js +++ b/public/app.js @@ -1,6 +1,7 @@ const queryInput = document.getElementById('query'); const searchBtn = document.getElementById('searchBtn'); const searchTypeSelect = document.getElementById('searchType'); +const existsFilterSelect = document.getElementById('existsFilter'); const results = document.getElementById('results'); const statusEl = document.getElementById('status'); const dialog = document.getElementById('albumDialog'); @@ -23,6 +24,7 @@ let selectedAlbum = null; let sendContext = null; let playlistTracks = []; let jobs = []; +let lastSearch = { type: null, items: [] }; const CLEANUP_KEY = 'cleanupExtras'; cleanupToggle.checked = localStorage.getItem(CLEANUP_KEY) === 'true'; @@ -128,12 +130,14 @@ function createPlaylistTrackCard(track, idx) { } function renderItems(type, items) { + lastSearch = { type, items: Array.isArray(items) ? items : [] }; results.innerHTML = ''; - if (!items.length) { + const filtered = applyExistsFilter(lastSearch.items, type); + if (!filtered.length) { results.innerHTML = '

Keine Treffer gefunden.

'; return; } - for (const item of items) { + for (const item of filtered) { let card; if (type === 'track') card = createTrackCard(item); if (type === 'artist') card = createArtistCard(item); @@ -142,6 +146,19 @@ function renderItems(type, items) { } } +function applyExistsFilter(items, type) { + const mode = existsFilterSelect.value || 'all'; + if (mode === 'all') return items; + if (type === 'artist') return items; + + return (items || []).filter((item) => { + const exists = Boolean(item.exists); + if (mode === 'exists') return exists; + if (mode === 'missing') return !exists; + return true; + }); +} + function updateDialogActionButton() { const rows = Array.from(trackList.querySelectorAll('input[type="checkbox"]')); const selected = rows.filter((x) => x.checked).map((x) => ({ @@ -344,13 +361,16 @@ async function updateFrontendFromGit() { } function renderPlaylist() { + lastSearch = { type: 'playlist-track', items: playlistTracks.slice() }; results.innerHTML = ''; - if (!playlistTracks.length) { + const filtered = applyExistsFilter(playlistTracks, 'track'); + if (!filtered.length) { results.innerHTML = '

Keine Playlist-Tracks gefunden.

'; playlistActions.style.display = 'none'; return; } - playlistTracks.forEach((track, idx) => { + filtered.forEach((track) => { + const idx = playlistTracks.indexOf(track); const card = createPlaylistTrackCard(track, idx); const cb = card.querySelector('input[type="checkbox"]'); cb.addEventListener('change', () => { @@ -433,5 +453,14 @@ updateLibraryBtn.addEventListener('click', updateLibrary); updateFrontendBtn.addEventListener('click', updateFrontendFromGit); importPlaylistBtn.addEventListener('click', importPlaylist); sendPlaylistBtn.addEventListener('click', sendPlaylistSelection); +existsFilterSelect.addEventListener('change', () => { + if (lastSearch.type === 'playlist-track') { + renderPlaylist(); + return; + } + if (lastSearch.type) { + renderItems(lastSearch.type, lastSearch.items); + } +}); renderJobs(); diff --git a/public/index.html b/public/index.html index d052bb6..4e8924f 100644 --- a/public/index.html +++ b/public/index.html @@ -55,6 +55,11 @@ + diff --git a/server.js b/server.js index 698780f..6d8c1a6 100644 --- a/server.js +++ b/server.js @@ -30,6 +30,7 @@ let spotifyTokenCache = { let libraryIndexCache = { byTitle: new Map(), byAlbum: new Map(), + byTrackTriplet: new Map(), trackById: new Map(), updatedAt: 0 }; @@ -91,6 +92,13 @@ function normalizeTitle(title) { .trim(); } +function normalizeStrict(value) { + return String(value || '') + .toLowerCase() + .replace(/\s+/g, ' ') + .trim(); +} + function buildTrackTitleKey(title) { return normalizeTitle(title); } @@ -99,6 +107,10 @@ function buildAlbumKey(albumName, artistName) { return `${normalize(albumName)}::${normalize(artistName)}`; } +function buildTrackTripletKey(trackName, albumName, artistName) { + return `${normalizeStrict(trackName)}::${normalizeStrict(albumName)}::${normalizeStrict(artistName)}`; +} + function hasFileForTrack(track) { return Boolean(track?.hasFile || track?.trackFileId || track?.fileId); } @@ -107,7 +119,7 @@ function extractTrackName(track) { return track?.title || track?.trackTitle || ''; } -function addTrackToLibraryIndex(track) { +function addTrackToLibraryIndex(track, albumMeta = null) { const titleKey = buildTrackTitleKey(extractTrackName(track)); if (!titleKey) return; @@ -119,22 +131,42 @@ function addTrackToLibraryIndex(track) { id: track.id, albumId: track.albumId, title: extractTrackName(track), + albumName: albumMeta?.albumName || '', + artistName: albumMeta?.artistName || '', hasFile: hasFileForTrack(track) }; libraryIndexCache.byTitle.get(titleKey).push(entry); if (track.id) { libraryIndexCache.trackById.set(track.id, entry); } + + const tripletKey = buildTrackTripletKey(entry.title, entry.albumName, entry.artistName); + if (tripletKey) { + if (!libraryIndexCache.byTrackTriplet.has(tripletKey)) { + libraryIndexCache.byTrackTriplet.set(tripletKey, []); + } + libraryIndexCache.byTrackTriplet.get(tripletKey).push(entry); + } } async function rebuildLibraryIndex() { const albums = await lidarrRequest('get', '/api/v1/album'); const byTitle = new Map(); const byAlbum = new Map(); + const byTrackTriplet = new Map(); const trackById = new Map(); const prev = libraryIndexCache; - libraryIndexCache = { byTitle, byAlbum, trackById, updatedAt: Date.now() }; + libraryIndexCache = { byTitle, byAlbum, byTrackTriplet, trackById, updatedAt: Date.now() }; + + const albumMetaById = new Map(); + for (const album of albums || []) { + if (!album?.id) continue; + albumMetaById.set(album.id, { + albumName: album.title || album.albumTitle || '', + artistName: album.artistName || album.artist?.artistName || '' + }); + } let trackCount = 0; for (const album of albums || []) { @@ -146,7 +178,7 @@ async function rebuildLibraryIndex() { tracks = []; } for (const track of tracks || []) { - addTrackToLibraryIndex(track); + addTrackToLibraryIndex(track, albumMetaById.get(album.id)); trackCount += 1; } } @@ -177,7 +209,36 @@ async function ensureLibraryIndexFresh(maxAgeMs = 5 * 60 * 1000) { } } -function getTrackLibraryState(trackName) { +function getTrackLibraryState(trackName, albumName, artistName) { + const tripletKey = buildTrackTripletKey(trackName, albumName, artistName); + const tripletMatches = libraryIndexCache.byTrackTriplet.get(tripletKey) || []; + if (tripletMatches.length > 0) { + const existing = tripletMatches.some((m) => m.hasFile); + return { + exists: existing, + matchCount: tripletMatches.length + }; + } + + // Fallback only when no album/artist context exists. + if (!albumName && !artistName) { + const key = buildTrackTitleKey(trackName); + const matches = libraryIndexCache.byTitle.get(key) || []; + const existing = matches.some((m) => m.hasFile); + return { + exists: existing, + matchCount: matches.length + }; + } + + // Strict mode with context: no full triplet match means "not existing". + return { + exists: false, + matchCount: 0 + }; +} + +function getTrackLibraryStateLoose(trackName) { const key = buildTrackTitleKey(trackName); const matches = libraryIndexCache.byTitle.get(key) || []; const existing = matches.some((m) => m.hasFile); @@ -631,7 +692,11 @@ app.get('/api/spotify/search', async (req, res) => { } if (type === 'track') { items = (data.tracks?.items || []).map((track) => ({ - ...getTrackLibraryState(track.name), + ...getTrackLibraryState( + track.name, + track.album?.name || '', + track.album?.artists?.[0]?.name || track.artists?.[0]?.name || '' + ), id: track.id, trackName: track.name, artist: track.artists?.map((a) => a.name).join(', ') || 'Unbekannt', @@ -698,7 +763,11 @@ app.get('/api/spotify/album/:id', async (req, res) => { name: track.name, durationMs: track.duration_ms, trackNumber: track.track_number, - exists: getTrackLibraryState(track.name).exists + exists: getTrackLibraryState( + track.name, + album.name, + album.artists?.[0]?.name || 'Unbekannt' + ).exists })) }); } catch (err) { @@ -734,7 +803,7 @@ app.post('/api/import/playlist', async (req, res) => { const enriched = tracks.map((t) => ({ ...t, - exists: getTrackLibraryState(t.trackName).exists + exists: getTrackLibraryState(t.trackName, t.albumName, t.albumArtist || t.artist).exists })); res.json({ success: true, tracks: enriched });