From 8b81f16dacf799cf6b5780351f7f7d2e4db1bfda Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 15:29:50 +0100 Subject: [PATCH] fix: proper multi-user tag badges using full Emby user list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: - Add getEmbyUsers(): fetches all Emby users, builds Map of lowercase/sanitized name -> display name, cached 60s - Add buildTagBadges(allTags, embyUserMap): classifies each tag as { label, matchedUser: displayName|null } against the full Emby user database - Attach tagBadges[] to every download object when showAll=true (all 10 construction sites across SABnzbd queue/history and qBittorrent queue/history blocks) - matchedUserTag still set to the tag matching the *current* user for the non-showAll badge Frontend: - showAll mode: renders tagBadges[] — unmatched tags (no Emby user) amber leftmost, matched tags show Emby display name in accent colour rightmost - Normal mode: renders matchedUserTag badge only (current user's tag) --- public/app.js | 22 +++++++++++----- server/routes/dashboard.js | 54 +++++++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/public/app.js b/public/app.js index 229d2e0..28e9b9c 100644 --- a/public/app.js +++ b/public/app.js @@ -434,16 +434,26 @@ function createDownloadCard(download) { infoDiv.appendChild(movie); } - if (showAll && download.allTags && download.allTags.length > 0) { - const unmatchedTags = download.allTags.filter(t => t !== download.matchedUserTag); - for (const tag of unmatchedTags) { + if (showAll && download.tagBadges && download.tagBadges.length > 0) { + // In showAll mode: render all tags classified by whether they match an Emby user. + // Unmatched (no known Emby user) → amber, leftmost. + // Matched → show Emby display name in accent colour, rightmost. + const unmatched = download.tagBadges.filter(b => !b.matchedUser); + const matched = download.tagBadges.filter(b => b.matchedUser); + for (const b of unmatched) { const badge = document.createElement('span'); badge.className = 'download-user-badge unmatched'; - badge.textContent = tag; + badge.textContent = b.label; header.appendChild(badge); } - } - if (download.matchedUserTag) { + for (const b of matched) { + const badge = document.createElement('span'); + badge.className = 'download-user-badge'; + badge.textContent = b.matchedUser; + header.appendChild(badge); + } + } else if (download.matchedUserTag) { + // Normal (non-showAll) view: show only the current user's matched tag const matchedBadge = document.createElement('span'); matchedBadge.className = 'download-user-badge'; matchedBadge.textContent = download.matchedUserTag; diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 5256b7e..3f93c78 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -94,6 +94,41 @@ function getRadarrLink(movie) { return `${movie._instanceUrl}/movie/${movie.titleSlug}`; } +// Fetch all Emby users and return a Map displayName> (and sanitized variants). +// Result is cached for 60s to avoid hammering Emby on every dashboard poll. +async function getEmbyUsers() { + const cached = cache.get('emby:users'); + if (cached) return cached; + try { + const response = await axios.get(`${EMBY_URL}/Users`, { + headers: { 'X-MediaBrowser-Token': EMBY_API_KEY } + }); + // Build map: both raw lowercase and sanitized form -> display name + const map = new Map(); + for (const u of response.data) { + const name = u.Name || ''; + map.set(name.toLowerCase(), name); + map.set(sanitizeTagLabel(name), name); + } + cache.set('emby:users', map, 60000); + return map; + } catch (err) { + console.error('[Dashboard] Failed to fetch Emby users:', err.message); + return new Map(); + } +} + +// Classify each tag label: matched to a known Emby user, or unmatched. +// Returns array of { label, matchedUser: string|null } +function buildTagBadges(allTags, embyUserMap) { + return allTags.map(label => { + const lower = label.toLowerCase(); + const sanitized = sanitizeTagLabel(label); + const displayName = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null; + return { label, matchedUser: displayName }; + }); +} + // Track active dashboard clients: Map const activeClients = new Map(); const CLIENT_STALE_MS = 30000; // consider client gone after 30s of no requests @@ -181,6 +216,9 @@ router.get('/user-downloads', async (req, res) => { const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label])); const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label])); + // When showing all downloads, fetch full Emby user list to classify tags + const embyUserMap = showAll ? await getEmbyUsers() : new Map(); + console.log(`[Dashboard] Cache data - Series: ${seriesMap.size}, Movies: ${moviesMap.size}, qBit: ${qbittorrentTorrents.length}`); // Match SABnzbd downloads to Sonarr/Radarr activity @@ -244,7 +282,8 @@ router.get('/user-downloads', async (req, res) => { seriesName: series.title, episodeInfo: sonarrMatch, allTags, - matchedUserTag: matchedUserTag || null + matchedUserTag: matchedUserTag || null, + tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; const issues = getImportIssues(sonarrMatch); if (issues) dlObj.importIssues = issues; @@ -285,7 +324,8 @@ router.get('/user-downloads', async (req, res) => { movieName: movie.title, movieInfo: radarrMatch, allTags, - matchedUserTag: matchedUserTag || null + matchedUserTag: matchedUserTag || null, + tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; const issues = getImportIssues(radarrMatch); if (issues) dlObj.importIssues = issues; @@ -339,7 +379,8 @@ router.get('/user-downloads', async (req, res) => { seriesName: series.title, episodeInfo: sonarrMatch, allTags, - matchedUserTag: matchedUserTag || null + matchedUserTag: matchedUserTag || null, + tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; if (isAdmin) { dlObj.downloadPath = slot.storage || null; @@ -374,7 +415,8 @@ router.get('/user-downloads', async (req, res) => { movieName: movie.title, movieInfo: radarrMatch, allTags, - matchedUserTag: matchedUserTag || null + matchedUserTag: matchedUserTag || null, + tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; if (isAdmin) { dlObj.downloadPath = slot.storage || null; @@ -441,6 +483,7 @@ router.get('/user-downloads', async (req, res) => { download.episodeInfo = sonarrMatch; download.allTags = allTags; download.matchedUserTag = matchedUserTag || null; + download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined; const sonarrIssues = getImportIssues(sonarrMatch); if (sonarrIssues) download.importIssues = sonarrIssues; if (isAdmin) { @@ -475,6 +518,7 @@ router.get('/user-downloads', async (req, res) => { download.movieInfo = radarrMatch; download.allTags = allTags; download.matchedUserTag = matchedUserTag || null; + download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined; const radarrIssues = getImportIssues(radarrMatch); if (radarrIssues) download.importIssues = radarrIssues; if (isAdmin) { @@ -509,6 +553,7 @@ router.get('/user-downloads', async (req, res) => { download.episodeInfo = sonarrHistoryMatch; download.allTags = allTags; download.matchedUserTag = matchedUserTag || null; + download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined; if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; @@ -541,6 +586,7 @@ router.get('/user-downloads', async (req, res) => { download.movieInfo = radarrHistoryMatch; download.allTags = allTags; download.matchedUserTag = matchedUserTag || null; + download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined; if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null;