From 24b7797b60c9f6043d5da81ea0cea2d2c7d59979 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 16 May 2026 15:14:33 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20multi-tag=20badges=20for=20showAll=20?= =?UTF-8?q?=E2=80=94=20amber=20for=20unmatched,=20accent=20for=20matched?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server: add extractAllTags() returning all tag labels for a series/movie - server: showAll now includes items with ANY tag (not just user-matched); non-admin path unchanged (must match current user's tag) - server: replace userTag with allTags[] + matchedUserTag on every download object - frontend: render all tags in header; unmatched tags amber (left), matched user tag in accent colour (rightmost); only visible in showAll mode - css: add --unmatched-tag-bg/color variables to all three themes (light, dark, mono) and .download-user-badge.unmatched style --- public/app.js | 19 ++++++--- public/style.css | 12 ++++++ server/routes/dashboard.js | 81 +++++++++++++++++++++++++++----------- 3 files changed, 83 insertions(+), 29 deletions(-) diff --git a/public/app.js b/public/app.js index ea037f6..81cc0c9 100644 --- a/public/app.js +++ b/public/app.js @@ -434,11 +434,20 @@ function createDownloadCard(download) { infoDiv.appendChild(movie); } - if (showAll && download.userTag) { - const userBadge = document.createElement('span'); - userBadge.className = 'download-user-badge'; - userBadge.textContent = download.userTag; - header.appendChild(userBadge); + if (showAll && download.allTags && download.allTags.length > 0) { + const unmatchedTags = download.allTags.filter(t => t !== download.matchedUserTag); + for (const tag of unmatchedTags) { + const badge = document.createElement('span'); + badge.className = 'download-user-badge unmatched'; + badge.textContent = tag; + header.appendChild(badge); + } + if (download.matchedUserTag) { + const matchedBadge = document.createElement('span'); + matchedBadge.className = 'download-user-badge'; + matchedBadge.textContent = download.matchedUserTag; + header.appendChild(matchedBadge); + } } const details = document.createElement('div'); diff --git a/public/style.css b/public/style.css index 178fd11..a8a04b1 100644 --- a/public/style.css +++ b/public/style.css @@ -64,6 +64,8 @@ --footer-text: rgba(255, 255, 255, 0.9); --input-bg: #ffffff; --select-bg: #ffffff; + --unmatched-tag-bg: #fff3e0; + --unmatched-tag-color: #e65100; } [data-theme="dark"] { @@ -100,6 +102,8 @@ --footer-text: rgba(200, 200, 220, 0.8); --input-bg: #2a2a3d; --select-bg: #2a2a3d; + --unmatched-tag-bg: #3d2a00; + --unmatched-tag-color: #ffb74d; } [data-theme="mono"] { @@ -136,6 +140,8 @@ --footer-text: rgba(180, 180, 180, 0.7); --input-bg: #252525; --select-bg: #252525; + --unmatched-tag-bg: #2a2a2a; + --unmatched-tag-color: #a0a0a0; } /* ===== Base ===== */ @@ -734,6 +740,12 @@ body { white-space: nowrap; } +.download-user-badge.unmatched { + background: var(--unmatched-tag-bg); + color: var(--unmatched-tag-color); + margin-left: 0; +} + /* ===== Status Button ===== */ .status-btn { padding: 4px 12px; diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index d537adc..7fc6e78 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -40,6 +40,15 @@ function extractUserTag(tags, tagMap) { return userTag ? userTag.label : null; } +// Return all resolved tag labels for a series/movie +function extractAllTags(tags, tagMap) { + if (!tags || tags.length === 0) return []; + if (tagMap) { + return tags.map(id => tagMap.get(id)).filter(Boolean); + } + return tags.map(t => t && t.label).filter(Boolean); +} + // Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim function sanitizeTagLabel(input) { if (!input) return ''; @@ -224,8 +233,10 @@ router.get('/user-downloads', async (req, res) => { if (sonarrMatch && sonarrMatch.seriesId) { const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series; if (series) { - const userTag = extractUserTag(series.tags, sonarrTagMap); - if (userTag && (showAll || tagMatchesUser(userTag, username))) { + const matchedUserTag = extractUserTag(series.tags, sonarrTagMap); + const allTags = extractAllTags(series.tags, sonarrTagMap); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { const dlObj = { type: 'series', title: nzbName, @@ -239,7 +250,8 @@ router.get('/user-downloads', async (req, res) => { eta: slot.timeleft, seriesName: series.title, episodeInfo: sonarrMatch, - userTag: userTag + allTags, + matchedUserTag: matchedUserTag || null }; const issues = getImportIssues(sonarrMatch); if (issues) dlObj.importIssues = issues; @@ -262,8 +274,10 @@ router.get('/user-downloads', async (req, res) => { if (radarrMatch && radarrMatch.movieId) { const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie; if (movie) { - const userTag = extractUserTag(movie.tags, radarrTagMap); - if (userTag && (showAll || tagMatchesUser(userTag, username))) { + const matchedUserTag = extractUserTag(movie.tags, radarrTagMap); + const allTags = extractAllTags(movie.tags, radarrTagMap); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { const dlObj = { type: 'movie', title: nzbName, @@ -277,7 +291,8 @@ router.get('/user-downloads', async (req, res) => { eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, - userTag: userTag + allTags, + matchedUserTag: matchedUserTag || null }; const issues = getImportIssues(radarrMatch); if (issues) dlObj.importIssues = issues; @@ -317,8 +332,10 @@ router.get('/user-downloads', async (req, res) => { if (sonarrMatch && sonarrMatch.seriesId) { const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series; if (series) { - const userTag = extractUserTag(series.tags, sonarrTagMap); - if (userTag && (showAll || tagMatchesUser(userTag, username))) { + const matchedUserTag = extractUserTag(series.tags, sonarrTagMap); + const allTags = extractAllTags(series.tags, sonarrTagMap); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { const dlObj = { type: 'series', title: nzbName, @@ -328,7 +345,8 @@ router.get('/user-downloads', async (req, res) => { completedAt: slot.completed_time, seriesName: series.title, episodeInfo: sonarrMatch, - userTag: userTag + allTags, + matchedUserTag: matchedUserTag || null }; if (isAdmin) { dlObj.downloadPath = slot.storage || null; @@ -349,8 +367,10 @@ router.get('/user-downloads', async (req, res) => { if (radarrMatch && radarrMatch.movieId) { const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie; if (movie) { - const userTag = extractUserTag(movie.tags, radarrTagMap); - if (userTag && (showAll || tagMatchesUser(userTag, username))) { + const matchedUserTag = extractUserTag(movie.tags, radarrTagMap); + const allTags = extractAllTags(movie.tags, radarrTagMap); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { const dlObj = { type: 'movie', title: nzbName, @@ -360,7 +380,8 @@ router.get('/user-downloads', async (req, res) => { completedAt: slot.completed_time, movieName: movie.title, movieInfo: radarrMatch, - userTag: userTag + allTags, + matchedUserTag: matchedUserTag || null }; if (isAdmin) { dlObj.downloadPath = slot.storage || null; @@ -417,15 +438,18 @@ router.get('/user-downloads', async (req, res) => { if (sonarrMatch && sonarrMatch.seriesId) { const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series; if (series) { - const userTag = extractUserTag(series.tags, sonarrTagMap); - if (userTag && (showAll || tagMatchesUser(userTag, username))) { + const matchedUserTag = extractUserTag(series.tags, sonarrTagMap); + const allTags = extractAllTags(series.tags, sonarrTagMap); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr series "${series.title}"`); const download = mapTorrentToDownload(torrent); download.type = 'series'; download.coverArt = getCoverArt(series); download.seriesName = series.title; download.episodeInfo = sonarrMatch; - download.userTag = userTag; + download.allTags = allTags; + download.matchedUserTag = matchedUserTag || null; const sonarrIssues = getImportIssues(sonarrMatch); if (sonarrIssues) download.importIssues = sonarrIssues; if (isAdmin) { @@ -448,15 +472,18 @@ router.get('/user-downloads', async (req, res) => { if (radarrMatch && radarrMatch.movieId) { const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie; if (movie) { - const userTag = extractUserTag(movie.tags, radarrTagMap); - if (userTag && (showAll || tagMatchesUser(userTag, username))) { + const matchedUserTag = extractUserTag(movie.tags, radarrTagMap); + const allTags = extractAllTags(movie.tags, radarrTagMap); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr movie "${movie.title}"`); const download = mapTorrentToDownload(torrent); download.type = 'movie'; download.coverArt = getCoverArt(movie); download.movieName = movie.title; download.movieInfo = radarrMatch; - download.userTag = userTag; + download.allTags = allTags; + download.matchedUserTag = matchedUserTag || null; const radarrIssues = getImportIssues(radarrMatch); if (radarrIssues) download.importIssues = radarrIssues; if (isAdmin) { @@ -479,15 +506,18 @@ router.get('/user-downloads', async (req, res) => { if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) { const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series; if (series) { - const userTag = extractUserTag(series.tags, sonarrTagMap); - if (userTag && (showAll || tagMatchesUser(userTag, username))) { + const matchedUserTag = extractUserTag(series.tags, sonarrTagMap); + const allTags = extractAllTags(series.tags, sonarrTagMap); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr history "${series.title}"`); const download = mapTorrentToDownload(torrent); download.type = 'series'; download.coverArt = getCoverArt(series); download.seriesName = series.title; download.episodeInfo = sonarrHistoryMatch; - download.userTag = userTag; + download.allTags = allTags; + download.matchedUserTag = matchedUserTag || null; if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; @@ -508,15 +538,18 @@ router.get('/user-downloads', async (req, res) => { if (radarrHistoryMatch && radarrHistoryMatch.movieId) { const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie; if (movie) { - const userTag = extractUserTag(movie.tags, radarrTagMap); - if (userTag && (showAll || tagMatchesUser(userTag, username))) { + const matchedUserTag = extractUserTag(movie.tags, radarrTagMap); + const allTags = extractAllTags(movie.tags, radarrTagMap); + const hasAnyTag = allTags.length > 0; + if (showAll ? hasAnyTag : (matchedUserTag && tagMatchesUser(matchedUserTag, username))) { console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr history "${movie.title}"`); const download = mapTorrentToDownload(torrent); download.type = 'movie'; download.coverArt = getCoverArt(movie); download.movieName = movie.title; download.movieInfo = radarrHistoryMatch; - download.userTag = userTag; + download.allTags = allTags; + download.matchedUserTag = matchedUserTag || null; if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null;