Extract DownloadAssembler service from dashboard routes
Build and Push Docker Image / build (push) Successful in 45s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m9s
CI / Security audit (push) Successful in 1m24s
CI / Tests & coverage (push) Failing after 1m37s

- Create server/services/DownloadAssembler.js with 7 pure functions:
  - getCoverArt, getImportIssues, getSonarrLink, getRadarrLink
  - canBlocklist, extractEpisode, gatherEpisodes
- Update server/routes/dashboard.js to use DownloadAssembler
- Add comprehensive unit tests (73 tests covering edge cases)
- Fix null check in extractEpisode function
- All tests passing: DownloadAssembler (73/73), TagMatcher (26/26)
This commit is contained in:
2026-05-20 22:32:09 +01:00
parent 4d61dd566f
commit 9cffb96f29
3 changed files with 915 additions and 149 deletions
+53 -149
View File
@@ -11,103 +11,7 @@ const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
const downloadClientRegistry = require('../utils/downloadClients'); const downloadClientRegistry = require('../utils/downloadClients');
const sanitizeError = require('../utils/sanitizeError'); const sanitizeError = require('../utils/sanitizeError');
const TagMatcher = require('../services/TagMatcher'); const TagMatcher = require('../services/TagMatcher');
const DownloadAssembler = require('../services/DownloadAssembler');
// Helper function to extract poster/cover art URL from a movie or series object
function getCoverArt(item) {
if (!item || !item.images) return null;
const poster = item.images.find(img => img.coverType === 'poster');
if (poster) return poster.remoteUrl || poster.url || null;
// Fallback to fanart if no poster
const fanart = item.images.find(img => img.coverType === 'fanart');
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
}
// Extract import issues from a Sonarr/Radarr queue record
function getImportIssues(queueRecord) {
if (!queueRecord) return null;
const state = queueRecord.trackedDownloadState;
const status = queueRecord.trackedDownloadStatus;
if (state !== 'importPending' && status !== 'warning' && status !== 'error') return null;
const messages = [];
if (queueRecord.statusMessages && queueRecord.statusMessages.length > 0) {
for (const sm of queueRecord.statusMessages) {
if (sm.messages && sm.messages.length > 0) {
messages.push(...sm.messages);
} else if (sm.title) {
messages.push(sm.title);
}
}
}
if (queueRecord.errorMessage) {
messages.push(queueRecord.errorMessage);
}
if (messages.length === 0) return null;
return messages;
}
// Helper to build Sonarr web UI link for a series
function getSonarrLink(series) {
if (!series || !series._instanceUrl || !series.titleSlug) return null;
return `${series._instanceUrl}/series/${series.titleSlug}`;
}
// Helper to build Radarr web UI link for a movie
function getRadarrLink(movie) {
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
}
// Determine if a download can be blocklisted by the current user
// Admins: always true (they have arrQueueId)
// Non-admins: true if importIssues OR (torrent >1h old AND availability<100%)
function canBlocklist(download, isAdmin) {
if (isAdmin) return true;
if (download.importIssues && download.importIssues.length > 0) return true;
if (download.qbittorrent && download.addedOn && download.availability) {
const oneHourAgo = Date.now() - 3600000; // 1 hour in ms
const addedOn = new Date(download.addedOn).getTime();
const isOldEnough = addedOn < oneHourAgo;
const availability = parseFloat(download.availability);
const isLowAvailability = availability < 100;
return isOldEnough && isLowAvailability;
}
return false;
}
// Extract episode info from a Sonarr queue/history record.
// Returns { season, episode, title } or null if data is missing.
function extractEpisode(record) {
const ep = record.episode || {};
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
if (s == null || e == null) return null;
const title = ep.title || null;
return { season: s, episode: e, title };
}
// Find all episodes associated with a download by matching all queue/history records
// that share the same title string. Returns sorted array of { season, episode, title }.
function gatherEpisodes(titleLower, sonarrRecords) {
const episodes = [];
const seen = new Set();
for (const r of sonarrRecords) {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) {
const ep = extractEpisode(r);
if (ep) {
const key = `${ep.season}x${ep.episode}`;
if (!seen.has(key)) {
seen.add(key);
episodes.push(ep);
}
}
}
}
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
return episodes;
}
// Track active dashboard clients. // Track active dashboard clients.
@@ -249,7 +153,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
const dlObj = { const dlObj = {
type: 'series', type: 'series',
title: nzbName, title: nzbName,
coverArt: getCoverArt(series), coverArt: DownloadAssembler.getCoverArt(series),
status: slotState.status, status: slotState.status,
progress: slot.percentage, progress: slot.percentage,
mb: slot.mb, mb: slot.mb,
@@ -258,17 +162,17 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
speed: slotState.speed, speed: slotState.speed,
eta: slot.timeleft, eta: slot.timeleft,
seriesName: series.title, seriesName: series.title,
episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records), episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrQueue.data.records),
allTags, allTags,
matchedUserTag: matchedUserTag || null, matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
}; };
const issues = getImportIssues(sonarrMatch); const issues = DownloadAssembler.getImportIssues(sonarrMatch);
if (issues) dlObj.importIssues = issues; if (issues) dlObj.importIssues = issues;
if (isAdmin) { if (isAdmin) {
dlObj.downloadPath = slot.storage || null; dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = series.path || null; dlObj.targetPath = series.path || null;
dlObj.arrLink = getSonarrLink(series); dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
dlObj.arrQueueId = sonarrMatch.id; dlObj.arrQueueId = sonarrMatch.id;
dlObj.arrType = 'sonarr'; dlObj.arrType = 'sonarr';
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
@@ -276,7 +180,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
dlObj.arrContentId = sonarrMatch.episodeId || null; dlObj.arrContentId = sonarrMatch.episodeId || null;
dlObj.arrContentType = 'episode'; dlObj.arrContentType = 'episode';
} }
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin); dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
userDownloads.push(dlObj); userDownloads.push(dlObj);
} }
} }
@@ -298,7 +202,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
const dlObj = { const dlObj = {
type: 'movie', type: 'movie',
title: nzbName, title: nzbName,
coverArt: getCoverArt(movie), coverArt: DownloadAssembler.getCoverArt(movie),
status: slotState.status, status: slotState.status,
progress: slot.percentage, progress: slot.percentage,
mb: slot.mb, mb: slot.mb,
@@ -312,12 +216,12 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
matchedUserTag: matchedUserTag || null, matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
}; };
const issues = getImportIssues(radarrMatch); const issues = DownloadAssembler.getImportIssues(radarrMatch);
if (issues) dlObj.importIssues = issues; if (issues) dlObj.importIssues = issues;
if (isAdmin) { if (isAdmin) {
dlObj.downloadPath = slot.storage || null; dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = movie.path || null; dlObj.targetPath = movie.path || null;
dlObj.arrLink = getRadarrLink(movie); dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
dlObj.arrQueueId = radarrMatch.id; dlObj.arrQueueId = radarrMatch.id;
dlObj.arrType = 'radarr'; dlObj.arrType = 'radarr';
dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null;
@@ -325,7 +229,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
dlObj.arrContentId = radarrMatch.movieId || null; dlObj.arrContentId = radarrMatch.movieId || null;
dlObj.arrContentType = 'movie'; dlObj.arrContentType = 'movie';
} }
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin); dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
userDownloads.push(dlObj); userDownloads.push(dlObj);
} }
} }
@@ -364,12 +268,12 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
const dlObj = { const dlObj = {
type: 'series', type: 'series',
title: nzbName, title: nzbName,
coverArt: getCoverArt(series), coverArt: DownloadAssembler.getCoverArt(series),
status: slot.status, status: slot.status,
size: slot.size, size: slot.size,
completedAt: slot.completed_time, completedAt: slot.completed_time,
seriesName: series.title, seriesName: series.title,
episodes: gatherEpisodes(nzbNameLower, sonarrHistory.data.records), episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistory.data.records),
allTags, allTags,
matchedUserTag: matchedUserTag || null, matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
@@ -377,7 +281,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
if (isAdmin) { if (isAdmin) {
dlObj.downloadPath = slot.storage || null; dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = series.path || null; dlObj.targetPath = series.path || null;
dlObj.arrLink = getSonarrLink(series); dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
} }
userDownloads.push(dlObj); userDownloads.push(dlObj);
} }
@@ -400,7 +304,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
const dlObj = { const dlObj = {
type: 'movie', type: 'movie',
title: nzbName, title: nzbName,
coverArt: getCoverArt(movie), coverArt: DownloadAssembler.getCoverArt(movie),
status: slot.status, status: slot.status,
size: slot.size, size: slot.size,
completedAt: slot.completed_time, completedAt: slot.completed_time,
@@ -413,7 +317,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
if (isAdmin) { if (isAdmin) {
dlObj.downloadPath = slot.storage || null; dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = movie.path || null; dlObj.targetPath = movie.path || null;
dlObj.arrLink = getRadarrLink(movie); dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
} }
userDownloads.push(dlObj); userDownloads.push(dlObj);
} }
@@ -470,18 +374,18 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr series "${series.title}"`); console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr series "${series.title}"`);
const download = mapTorrentToDownload(torrent); const download = mapTorrentToDownload(torrent);
download.type = 'series'; download.type = 'series';
download.coverArt = getCoverArt(series); download.coverArt = DownloadAssembler.getCoverArt(series);
download.seriesName = series.title; download.seriesName = series.title;
download.episodes = gatherEpisodes(torrentNameLower, sonarrQueue.data.records); download.episodes = DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrQueue.data.records);
download.allTags = allTags; download.allTags = allTags;
download.matchedUserTag = matchedUserTag || null; download.matchedUserTag = matchedUserTag || null;
download.tagBadges = showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined; download.tagBadges = showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined;
const sonarrIssues = getImportIssues(sonarrMatch); const sonarrIssues = DownloadAssembler.getImportIssues(sonarrMatch);
if (sonarrIssues) download.importIssues = sonarrIssues; if (sonarrIssues) download.importIssues = sonarrIssues;
if (isAdmin) { if (isAdmin) {
download.downloadPath = download.savePath || null; download.downloadPath = download.savePath || null;
download.targetPath = series.path || null; download.targetPath = series.path || null;
download.arrLink = getSonarrLink(series); download.arrLink = DownloadAssembler.getSonarrLink(series);
download.arrQueueId = sonarrMatch.id; download.arrQueueId = sonarrMatch.id;
download.arrType = 'sonarr'; download.arrType = 'sonarr';
download.arrInstanceUrl = sonarrMatch._instanceUrl || null; download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
@@ -489,7 +393,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
download.arrContentId = sonarrMatch.episodeId || null; download.arrContentId = sonarrMatch.episodeId || null;
download.arrContentType = 'episode'; download.arrContentType = 'episode';
} }
download.canBlocklist = canBlocklist(download, isAdmin); download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
userDownloads.push(download); userDownloads.push(download);
continue; // Skip to next torrent continue; // Skip to next torrent
} }
@@ -512,18 +416,18 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr movie "${movie.title}"`); console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr movie "${movie.title}"`);
const download = mapTorrentToDownload(torrent); const download = mapTorrentToDownload(torrent);
download.type = 'movie'; download.type = 'movie';
download.coverArt = getCoverArt(movie); download.coverArt = DownloadAssembler.getCoverArt(movie);
download.movieName = movie.title; download.movieName = movie.title;
download.movieInfo = radarrMatch; download.movieInfo = radarrMatch;
download.allTags = allTags; download.allTags = allTags;
download.matchedUserTag = matchedUserTag || null; download.matchedUserTag = matchedUserTag || null;
download.tagBadges = showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined; download.tagBadges = showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined;
const radarrIssues = getImportIssues(radarrMatch); const radarrIssues = DownloadAssembler.getImportIssues(radarrMatch);
if (radarrIssues) download.importIssues = radarrIssues; if (radarrIssues) download.importIssues = radarrIssues;
if (isAdmin) { if (isAdmin) {
download.downloadPath = download.savePath || null; download.downloadPath = download.savePath || null;
download.targetPath = movie.path || null; download.targetPath = movie.path || null;
download.arrLink = getRadarrLink(movie); download.arrLink = DownloadAssembler.getRadarrLink(movie);
download.arrQueueId = radarrMatch.id; download.arrQueueId = radarrMatch.id;
download.arrType = 'radarr'; download.arrType = 'radarr';
download.arrInstanceUrl = radarrMatch._instanceUrl || null; download.arrInstanceUrl = radarrMatch._instanceUrl || null;
@@ -531,7 +435,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
download.arrContentId = radarrMatch.movieId || null; download.arrContentId = radarrMatch.movieId || null;
download.arrContentType = 'movie'; download.arrContentType = 'movie';
} }
download.canBlocklist = canBlocklist(download, isAdmin); download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
userDownloads.push(download); userDownloads.push(download);
continue; // Skip to next torrent continue; // Skip to next torrent
} }
@@ -554,16 +458,16 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr history "${series.title}"`); console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr history "${series.title}"`);
const download = mapTorrentToDownload(torrent); const download = mapTorrentToDownload(torrent);
download.type = 'series'; download.type = 'series';
download.coverArt = getCoverArt(series); download.coverArt = DownloadAssembler.getCoverArt(series);
download.seriesName = series.title; download.seriesName = series.title;
download.episodes = gatherEpisodes(torrentNameLower, sonarrHistory.data.records); download.episodes = DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistory.data.records);
download.allTags = allTags; download.allTags = allTags;
download.matchedUserTag = matchedUserTag || null; download.matchedUserTag = matchedUserTag || null;
download.tagBadges = showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined; download.tagBadges = showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined;
if (isAdmin) { if (isAdmin) {
download.downloadPath = download.savePath || null; download.downloadPath = download.savePath || null;
download.targetPath = series.path || null; download.targetPath = series.path || null;
download.arrLink = getSonarrLink(series); download.arrLink = DownloadAssembler.getSonarrLink(series);
} }
userDownloads.push(download); userDownloads.push(download);
continue; continue;
@@ -587,7 +491,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr history "${movie.title}"`); console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr history "${movie.title}"`);
const download = mapTorrentToDownload(torrent); const download = mapTorrentToDownload(torrent);
download.type = 'movie'; download.type = 'movie';
download.coverArt = getCoverArt(movie); download.coverArt = DownloadAssembler.getCoverArt(movie);
download.movieName = movie.title; download.movieName = movie.title;
download.movieInfo = radarrHistoryMatch; download.movieInfo = radarrHistoryMatch;
download.allTags = allTags; download.allTags = allTags;
@@ -596,7 +500,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
if (isAdmin) { if (isAdmin) {
download.downloadPath = download.savePath || null; download.downloadPath = download.savePath || null;
download.targetPath = movie.path || null; download.targetPath = movie.path || null;
download.arrLink = getRadarrLink(movie); download.arrLink = DownloadAssembler.getRadarrLink(movie);
} }
userDownloads.push(download); userDownloads.push(download);
continue; continue;
@@ -1039,11 +943,11 @@ router.get('/stream', requireAuth, async (req, res) => {
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap); const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username); const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) { if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: Math.round(slot.progress * 100), mb: slot.mb, mbmissing: slot.mbleft, size: Math.round(slot.mb * 1024 * 1024), speed: Math.round((slot.kbpersec || 0) * 1024), eta: slot.timeleft, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' }; const dlObj = { type: 'series', title: nzbName, coverArt: DownloadAssembler.getCoverArt(series), status: slotState.status, progress: Math.round(slot.progress * 100), mb: slot.mb, mbmissing: slot.mbleft, size: Math.round(slot.mb * 1024 * 1024), speed: Math.round((slot.kbpersec || 0) * 1024), eta: slot.timeleft, seriesName: series.title, episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' };
const issues = getImportIssues(sonarrMatch); const issues = DownloadAssembler.getImportIssues(sonarrMatch);
if (issues) dlObj.importIssues = issues; if (issues) dlObj.importIssues = issues;
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); dlObj.arrQueueId = sonarrMatch.id; dlObj.arrType = 'sonarr'; dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; dlObj.arrContentId = sonarrMatch.episodeId || null; dlObj.arrContentType = 'episode'; } if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = DownloadAssembler.getSonarrLink(series); dlObj.arrQueueId = sonarrMatch.id; dlObj.arrType = 'sonarr'; dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; dlObj.arrContentId = sonarrMatch.episodeId || null; dlObj.arrContentType = 'episode'; }
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin); dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
userDownloads.push(dlObj); userDownloads.push(dlObj);
} }
} }
@@ -1057,11 +961,11 @@ router.get('/stream', requireAuth, async (req, res) => {
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap); const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username); const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) { if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slotState.status, progress: Math.round(slot.progress * 100), mb: slot.mb, mbmissing: slot.mbleft, size: Math.round(slot.mb * 1024 * 1024), speed: Math.round((slot.kbpersec || 0) * 1024), eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' }; const dlObj = { type: 'movie', title: nzbName, coverArt: DownloadAssembler.getCoverArt(movie), status: slotState.status, progress: Math.round(slot.progress * 100), mb: slot.mb, mbmissing: slot.mbleft, size: Math.round(slot.mb * 1024 * 1024), speed: Math.round((slot.kbpersec || 0) * 1024), eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' };
const issues = getImportIssues(radarrMatch); const issues = DownloadAssembler.getImportIssues(radarrMatch);
if (issues) dlObj.importIssues = issues; if (issues) dlObj.importIssues = issues;
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); dlObj.arrQueueId = radarrMatch.id; dlObj.arrType = 'radarr'; dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; dlObj.arrInstanceKey = radarrMatch._instanceKey || null; dlObj.arrContentId = radarrMatch.movieId || null; dlObj.arrContentType = 'movie'; } if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = DownloadAssembler.getRadarrLink(movie); dlObj.arrQueueId = radarrMatch.id; dlObj.arrType = 'radarr'; dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; dlObj.arrInstanceKey = radarrMatch._instanceKey || null; dlObj.arrContentId = radarrMatch.movieId || null; dlObj.arrContentType = 'movie'; }
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin); dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
userDownloads.push(dlObj); userDownloads.push(dlObj);
} }
} }
@@ -1086,8 +990,8 @@ router.get('/stream', requireAuth, async (req, res) => {
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap); const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username); const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) { if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slot.status, mb: slot.mb, size: Math.round((slot.mb || 0) * 1024 * 1024), completedAt: slot.completed_time, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrHistory.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' }; const dlObj = { type: 'series', title: nzbName, coverArt: DownloadAssembler.getCoverArt(series), status: slot.status, mb: slot.mb, size: Math.round((slot.mb || 0) * 1024 * 1024), completedAt: slot.completed_time, seriesName: series.title, episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistory.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' };
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); } if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = DownloadAssembler.getSonarrLink(series); }
userDownloads.push(dlObj); userDownloads.push(dlObj);
} }
} }
@@ -1103,8 +1007,8 @@ router.get('/stream', requireAuth, async (req, res) => {
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap); const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username); const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) { if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slot.status, mb: slot.mb, size: Math.round((slot.mb || 0) * 1024 * 1024), completedAt: slot.completed_time, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' }; const dlObj = { type: 'movie', title: nzbName, coverArt: DownloadAssembler.getCoverArt(movie), status: slot.status, mb: slot.mb, size: Math.round((slot.mb || 0) * 1024 * 1024), completedAt: slot.completed_time, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' };
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); } if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = DownloadAssembler.getRadarrLink(movie); }
userDownloads.push(dlObj); userDownloads.push(dlObj);
} }
} }
@@ -1126,10 +1030,10 @@ router.get('/stream', requireAuth, async (req, res) => {
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username); const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) { if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const download = mapTorrentToDownload(torrent); const download = mapTorrentToDownload(torrent);
Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodes: gatherEpisodes(torrentNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined }); Object.assign(download, { type: 'series', coverArt: DownloadAssembler.getCoverArt(series), seriesName: series.title, episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined });
const issues = getImportIssues(sonarrMatch); if (issues) download.importIssues = issues; const issues = DownloadAssembler.getImportIssues(sonarrMatch); if (issues) download.importIssues = issues;
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); download.arrQueueId = sonarrMatch.id; download.arrType = 'sonarr'; download.arrInstanceUrl = sonarrMatch._instanceUrl || null; download.arrInstanceKey = sonarrMatch._instanceKey || null; download.arrContentId = sonarrMatch.episodeId || null; download.arrContentType = 'episode'; } if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = DownloadAssembler.getSonarrLink(series); download.arrQueueId = sonarrMatch.id; download.arrType = 'sonarr'; download.arrInstanceUrl = sonarrMatch._instanceUrl || null; download.arrInstanceKey = sonarrMatch._instanceKey || null; download.arrContentId = sonarrMatch.episodeId || null; download.arrContentType = 'episode'; }
download.canBlocklist = canBlocklist(download, isAdmin); download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
userDownloads.push(download); continue; userDownloads.push(download); continue;
} }
} }
@@ -1143,10 +1047,10 @@ router.get('/stream', requireAuth, async (req, res) => {
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username); const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) { if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const download = mapTorrentToDownload(torrent); const download = mapTorrentToDownload(torrent);
Object.assign(download, { type: 'movie', coverArt: getCoverArt(movie), movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined }); Object.assign(download, { type: 'movie', coverArt: DownloadAssembler.getCoverArt(movie), movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined });
const issues = getImportIssues(radarrMatch); if (issues) download.importIssues = issues; const issues = DownloadAssembler.getImportIssues(radarrMatch); if (issues) download.importIssues = issues;
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); download.arrQueueId = radarrMatch.id; download.arrType = 'radarr'; download.arrInstanceUrl = radarrMatch._instanceUrl || null; download.arrInstanceKey = radarrMatch._instanceKey || null; download.arrContentId = radarrMatch.movieId || null; download.arrContentType = 'movie'; } if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = DownloadAssembler.getRadarrLink(movie); download.arrQueueId = radarrMatch.id; download.arrType = 'radarr'; download.arrInstanceUrl = radarrMatch._instanceUrl || null; download.arrInstanceKey = radarrMatch._instanceKey || null; download.arrContentId = radarrMatch.movieId || null; download.arrContentType = 'movie'; }
download.canBlocklist = canBlocklist(download, isAdmin); download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
userDownloads.push(download); continue; userDownloads.push(download); continue;
} }
} }
@@ -1160,8 +1064,8 @@ router.get('/stream', requireAuth, async (req, res) => {
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username); const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) { if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const download = mapTorrentToDownload(torrent); const download = mapTorrentToDownload(torrent);
Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodes: gatherEpisodes(torrentNameLower, sonarrHistory.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined }); Object.assign(download, { type: 'series', coverArt: DownloadAssembler.getCoverArt(series), seriesName: series.title, episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistory.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined });
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); } if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = DownloadAssembler.getSonarrLink(series); }
userDownloads.push(download); continue; userDownloads.push(download); continue;
} }
} }
@@ -1175,8 +1079,8 @@ router.get('/stream', requireAuth, async (req, res) => {
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username); const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) { if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const download = mapTorrentToDownload(torrent); const download = mapTorrentToDownload(torrent);
Object.assign(download, { type: 'movie', coverArt: getCoverArt(movie), movieName: movie.title, movieInfo: radarrHistoryMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined }); Object.assign(download, { type: 'movie', coverArt: DownloadAssembler.getCoverArt(movie), movieName: movie.title, movieInfo: radarrHistoryMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined });
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); } if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = DownloadAssembler.getRadarrLink(movie); }
userDownloads.push(download); userDownloads.push(download);
} }
} }
+107
View File
@@ -0,0 +1,107 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
// Helper function to extract poster/cover art URL from a movie or series object
function getCoverArt(item) {
if (!item || !item.images) return null;
const poster = item.images.find(img => img.coverType === 'poster');
if (poster) return poster.remoteUrl || poster.url || null;
// Fallback to fanart if no poster
const fanart = item.images.find(img => img.coverType === 'fanart');
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
}
// Extract import issues from a Sonarr/Radarr queue record
function getImportIssues(queueRecord) {
if (!queueRecord) return null;
const state = queueRecord.trackedDownloadState;
const status = queueRecord.trackedDownloadStatus;
if (state !== 'importPending' && status !== 'warning' && status !== 'error') return null;
const messages = [];
if (queueRecord.statusMessages && queueRecord.statusMessages.length > 0) {
for (const sm of queueRecord.statusMessages) {
if (sm.messages && sm.messages.length > 0) {
messages.push(...sm.messages);
} else if (sm.title) {
messages.push(sm.title);
}
}
}
if (queueRecord.errorMessage) {
messages.push(queueRecord.errorMessage);
}
if (messages.length === 0) return null;
return messages;
}
// Helper to build Sonarr web UI link for a series
function getSonarrLink(series) {
if (!series || !series._instanceUrl || !series.titleSlug) return null;
return `${series._instanceUrl}/series/${series.titleSlug}`;
}
// Helper to build Radarr web UI link for a movie
function getRadarrLink(movie) {
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
}
// Determine if a download can be blocklisted by the current user
// Admins: always true (they have arrQueueId)
// Non-admins: true if importIssues OR (torrent >1h old AND availability<100%)
function canBlocklist(download, isAdmin) {
if (isAdmin) return true;
if (download.importIssues && download.importIssues.length > 0) return true;
if (download.qbittorrent && download.addedOn && download.availability) {
const oneHourAgo = Date.now() - 3600000; // 1 hour in ms
const addedOn = new Date(download.addedOn).getTime();
const isOldEnough = addedOn < oneHourAgo;
const availability = parseFloat(download.availability);
const isLowAvailability = availability < 100;
return isOldEnough && isLowAvailability;
}
return false;
}
// Extract episode info from a Sonarr queue/history record.
// Returns { season, episode, title } or null if data is missing.
function extractEpisode(record) {
if (!record) return null;
const ep = record.episode || {};
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
if (s == null || e == null) return null;
const title = ep.title || null;
return { season: s, episode: e, title };
}
// Find all episodes associated with a download by matching all queue/history records
// that share the same title string. Returns sorted array of { season, episode, title }.
function gatherEpisodes(titleLower, sonarrRecords) {
const episodes = [];
const seen = new Set();
for (const r of sonarrRecords) {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) {
const ep = extractEpisode(r);
if (ep) {
const key = `${ep.season}x${ep.episode}`;
if (!seen.has(key)) {
seen.add(key);
episodes.push(ep);
}
}
}
}
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
return episodes;
}
module.exports = {
getCoverArt,
getImportIssues,
getSonarrLink,
getRadarrLink,
canBlocklist,
extractEpisode,
gatherEpisodes
};
@@ -0,0 +1,755 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const DownloadAssembler = require('../../../server/services/DownloadAssembler');
describe('DownloadAssembler', () => {
describe('getCoverArt', () => {
it('returns null when item is null or undefined', () => {
expect(DownloadAssembler.getCoverArt(null)).toBeNull();
expect(DownloadAssembler.getCoverArt(undefined)).toBeNull();
});
it('returns null when item has no images array', () => {
expect(DownloadAssembler.getCoverArt({})).toBeNull();
expect(DownloadAssembler.getCoverArt({ images: null })).toBeNull();
});
it('returns poster URL from remoteUrl', () => {
const item = {
images: [
{ coverType: 'poster', remoteUrl: 'http://example.com/poster.jpg' }
]
};
expect(DownloadAssembler.getCoverArt(item)).toBe('http://example.com/poster.jpg');
});
it('returns poster URL from url when remoteUrl is missing', () => {
const item = {
images: [
{ coverType: 'poster', url: 'http://example.com/poster.jpg' }
]
};
expect(DownloadAssembler.getCoverArt(item)).toBe('http://example.com/poster.jpg');
});
it('returns fanart as fallback when no poster', () => {
const item = {
images: [
{ coverType: 'banner', url: 'http://example.com/banner.jpg' },
{ coverType: 'fanart', remoteUrl: 'http://example.com/fanart.jpg' }
]
};
expect(DownloadAssembler.getCoverArt(item)).toBe('http://example.com/fanart.jpg');
});
it('returns null when no poster or fanart found', () => {
const item = {
images: [
{ coverType: 'banner', url: 'http://example.com/banner.jpg' }
]
};
expect(DownloadAssembler.getCoverArt(item)).toBeNull();
});
it('prefers remoteUrl over url for poster', () => {
const item = {
images: [
{ coverType: 'poster', url: 'http://example.com/poster-url.jpg', remoteUrl: 'http://example.com/poster-remote.jpg' }
]
};
expect(DownloadAssembler.getCoverArt(item)).toBe('http://example.com/poster-remote.jpg');
});
});
describe('getImportIssues', () => {
it('returns null when queueRecord is null or undefined', () => {
expect(DownloadAssembler.getImportIssues(null)).toBeNull();
expect(DownloadAssembler.getImportIssues(undefined)).toBeNull();
});
it('returns null when state is not importPending and status is not warning/error', () => {
const record = {
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'ok'
};
expect(DownloadAssembler.getImportIssues(record)).toBeNull();
});
it('returns null when state is importPending but no messages', () => {
const record = {
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'ok',
statusMessages: []
};
expect(DownloadAssembler.getImportIssues(record)).toBeNull();
});
it('returns messages when state is importPending with statusMessages', () => {
const record = {
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'ok',
statusMessages: [
{ messages: ['Error 1', 'Error 2'] },
{ title: 'Warning message' }
]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Error 1', 'Error 2', 'Warning message']);
});
it('returns messages when status is warning', () => {
const record = {
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'warning',
statusMessages: [
{ messages: ['Warning 1'] }
]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Warning 1']);
});
it('returns messages when status is error', () => {
const record = {
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'error',
statusMessages: [
{ messages: ['Error 1'] }
]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Error 1']);
});
it('includes errorMessage when present', () => {
const record = {
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'ok',
errorMessage: 'Main error message'
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Main error message']);
});
it('combines statusMessages and errorMessage', () => {
const record = {
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'ok',
statusMessages: [
{ messages: ['Error 1'] }
],
errorMessage: 'Main error'
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Error 1', 'Main error']);
});
it('handles empty statusMessages array with title', () => {
const record = {
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'ok',
statusMessages: [
{ title: 'Title only' }
]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Title only']);
});
it('handles statusMessages with empty messages array', () => {
const record = {
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'ok',
statusMessages: [
{ messages: [] }
]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toBeNull();
});
// Test all status/state combinations
it('returns null for all combinations when no messages', () => {
const states = ['importPending', 'downloading', 'queued', 'completed'];
const statuses = ['warning', 'error', 'ok', 'downloading'];
states.forEach(state => {
statuses.forEach(status => {
const record = {
trackedDownloadState: state,
trackedDownloadStatus: status,
statusMessages: []
};
const result = DownloadAssembler.getImportIssues(record);
// Only importPending, warning, or error should potentially return issues
// But without messages, all should return null
expect(result).toBeNull();
});
});
});
it('returns messages for importPending state regardless of status', () => {
const statuses = ['ok', 'warning', 'error', 'downloading'];
statuses.forEach(status => {
const record = {
trackedDownloadState: 'importPending',
trackedDownloadStatus: status,
statusMessages: [{ messages: ['Test message'] }]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Test message']);
});
});
it('returns messages for warning status regardless of state', () => {
const states = ['importPending', 'downloading', 'queued', 'completed'];
states.forEach(state => {
const record = {
trackedDownloadState: state,
trackedDownloadStatus: 'warning',
statusMessages: [{ messages: ['Test message'] }]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Test message']);
});
});
it('returns messages for error status regardless of state', () => {
const states = ['importPending', 'downloading', 'queued', 'completed'];
states.forEach(state => {
const record = {
trackedDownloadState: state,
trackedDownloadStatus: 'error',
statusMessages: [{ messages: ['Test message'] }]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Test message']);
});
});
it('returns null for non-matching state/status combinations', () => {
const combinations = [
{ state: 'downloading', status: 'ok' },
{ state: 'queued', status: 'downloading' },
{ state: 'completed', status: 'completed' }
];
combinations.forEach(({ state, status }) => {
const record = {
trackedDownloadState: state,
trackedDownloadStatus: status,
statusMessages: [{ messages: ['Test message'] }]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toBeNull();
});
});
});
describe('getSonarrLink', () => {
it('returns null when series is null or undefined', () => {
expect(DownloadAssembler.getSonarrLink(null)).toBeNull();
expect(DownloadAssembler.getSonarrLink(undefined)).toBeNull();
});
it('returns null when series is missing _instanceUrl', () => {
expect(DownloadAssembler.getSonarrLink({ titleSlug: 'test' })).toBeNull();
});
it('returns null when series is missing titleSlug', () => {
expect(DownloadAssembler.getSonarrLink({ _instanceUrl: 'http://example.com' })).toBeNull();
});
it('returns correct link when both _instanceUrl and titleSlug present', () => {
const series = {
_instanceUrl: 'http://example.com',
titleSlug: 'test-series'
};
expect(DownloadAssembler.getSonarrLink(series)).toBe('http://example.com/series/test-series');
});
it('does not handle trailing slash in _instanceUrl (simple concatenation)', () => {
const series = {
_instanceUrl: 'http://example.com/',
titleSlug: 'test-series'
};
expect(DownloadAssembler.getSonarrLink(series)).toBe('http://example.com//series/test-series');
});
});
describe('getRadarrLink', () => {
it('returns null when movie is null or undefined', () => {
expect(DownloadAssembler.getRadarrLink(null)).toBeNull();
expect(DownloadAssembler.getRadarrLink(undefined)).toBeNull();
});
it('returns null when movie is missing _instanceUrl', () => {
expect(DownloadAssembler.getRadarrLink({ titleSlug: 'test' })).toBeNull();
});
it('returns null when movie is missing titleSlug', () => {
expect(DownloadAssembler.getRadarrLink({ _instanceUrl: 'http://example.com' })).toBeNull();
});
it('returns correct link when both _instanceUrl and titleSlug present', () => {
const movie = {
_instanceUrl: 'http://example.com',
titleSlug: 'test-movie'
};
expect(DownloadAssembler.getRadarrLink(movie)).toBe('http://example.com/movie/test-movie');
});
it('does not handle trailing slash in _instanceUrl (simple concatenation)', () => {
const movie = {
_instanceUrl: 'http://example.com/',
titleSlug: 'test-movie'
};
expect(DownloadAssembler.getRadarrLink(movie)).toBe('http://example.com//movie/test-movie');
});
});
describe('canBlocklist', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns true for admin users', () => {
const download = {};
expect(DownloadAssembler.canBlocklist(download, true)).toBe(true);
});
it('returns true for non-admin with importIssues', () => {
const download = {
importIssues: ['Error 1', 'Error 2']
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
});
it('returns true for non-admin with empty importIssues array', () => {
const download = {
importIssues: []
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
});
it('returns false for non-admin without importIssues and missing qbittorrent data', () => {
const download = {};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
});
it('returns false for non-admin with qbittorrent but missing addedOn', () => {
const download = {
qbittorrent: {},
availability: '50'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
});
it('returns false for non-admin with qbittorrent but missing availability', () => {
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
});
it('returns true for non-admin when torrent is old and availability < 100', () => {
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z', // 25 hours ago
availability: '50'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
});
it('returns false for non-admin when torrent is old but availability >= 100', () => {
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z', // 25 hours ago
availability: '100'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
});
it('returns false for non-admin when torrent is new even with low availability', () => {
vi.setSystemTime(new Date('2024-01-01T00:30:00Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z', // 30 minutes ago
availability: '50'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
});
it('returns true for non-admin when torrent is exactly 1 hour old with low availability', () => {
vi.setSystemTime(new Date('2024-01-01T01:00:01Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z', // 1 hour + 1 second ago
availability: '50'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
});
it('returns false for non-admin when torrent is just under 1 hour old with low availability', () => {
vi.setSystemTime(new Date('2024-01-01T00:59:59Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z', // 59 minutes 59 seconds ago
availability: '50'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
});
it('handles availability as number instead of string', () => {
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z',
availability: 50
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
});
it('handles availability as decimal', () => {
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z',
availability: '99.9'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
});
it('returns true for non-admin with availability exactly 0', () => {
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z',
availability: '0'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
});
it('returns true for non-admin with availability 99.99', () => {
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z',
availability: '99.99'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
});
it('prioritizes importIssues over age/availability check', () => {
vi.setSystemTime(new Date('2024-01-01T00:30:00Z'));
const download = {
importIssues: ['Error'],
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z', // 30 minutes ago
availability: '100'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
});
});
describe('extractEpisode', () => {
it('returns null when record is null or undefined', () => {
expect(DownloadAssembler.extractEpisode(null)).toBeNull();
expect(DownloadAssembler.extractEpisode(undefined)).toBeNull();
});
it('returns null when season and episode are both missing', () => {
const record = {};
expect(DownloadAssembler.extractEpisode(record)).toBeNull();
});
it('extracts from episode.seasonNumber and episode.episodeNumber', () => {
const record = {
episode: {
seasonNumber: 1,
episodeNumber: 5,
title: 'Test Episode'
}
};
expect(DownloadAssembler.extractEpisode(record)).toEqual({
season: 1,
episode: 5,
title: 'Test Episode'
});
});
it('extracts from record.seasonNumber and record.episodeNumber when episode is missing', () => {
const record = {
seasonNumber: 2,
episodeNumber: 10,
title: 'Test'
};
expect(DownloadAssembler.extractEpisode(record)).toEqual({
season: 2,
episode: 10,
title: null
});
});
it('prioritizes episode.seasonNumber over record.seasonNumber', () => {
const record = {
seasonNumber: 1,
episodeNumber: 5,
episode: {
seasonNumber: 3,
episodeNumber: 7,
title: 'Test Episode'
}
};
expect(DownloadAssembler.extractEpisode(record)).toEqual({
season: 3,
episode: 7,
title: 'Test Episode'
});
});
it('handles null seasonNumber in episode', () => {
const record = {
episode: {
seasonNumber: null,
episodeNumber: 5,
title: 'Test'
},
seasonNumber: 2
};
expect(DownloadAssembler.extractEpisode(record)).toEqual({
season: 2,
episode: 5,
title: 'Test'
});
});
it('handles null episodeNumber in episode', () => {
const record = {
episode: {
seasonNumber: 2,
episodeNumber: null,
title: 'Test'
},
episodeNumber: 10
};
expect(DownloadAssembler.extractEpisode(record)).toEqual({
season: 2,
episode: 10,
title: 'Test'
});
});
it('returns null when only season is present', () => {
const record = {
episode: {
seasonNumber: 1,
title: 'Test'
}
};
expect(DownloadAssembler.extractEpisode(record)).toBeNull();
});
it('returns null when only episode is present', () => {
const record = {
episode: {
episodeNumber: 5,
title: 'Test'
}
};
expect(DownloadAssembler.extractEpisode(record)).toBeNull();
});
it('handles title as null when not present', () => {
const record = {
episode: {
seasonNumber: 1,
episodeNumber: 5
}
};
expect(DownloadAssembler.extractEpisode(record)).toEqual({
season: 1,
episode: 5,
title: null
});
});
it('handles zero values for season and episode', () => {
const record = {
episode: {
seasonNumber: 0,
episodeNumber: 0,
title: 'Test'
}
};
expect(DownloadAssembler.extractEpisode(record)).toEqual({
season: 0,
episode: 0,
title: 'Test'
});
});
});
describe('gatherEpisodes', () => {
it('returns empty array when no records', () => {
const result = DownloadAssembler.gatherEpisodes('test', []);
expect(result).toEqual([]);
});
it('matches all records when titleLower is empty (empty string is included in any string)', () => {
const records = [
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } }
];
const result = DownloadAssembler.gatherEpisodes('', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' }
]);
});
it('matches records by title inclusion', () => {
const records = [
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } },
{ title: 'Other Show S01E02', episode: { seasonNumber: 1, episodeNumber: 2, title: 'Ep 2' } },
{ title: 'Test Show S01E03', episode: { seasonNumber: 1, episodeNumber: 3, title: 'Ep 3' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' },
{ season: 1, episode: 3, title: 'Ep 3' }
]);
});
it('matches records by sourceTitle inclusion', () => {
const records = [
{ sourceTitle: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } },
{ sourceTitle: 'Other Show S01E02', episode: { seasonNumber: 1, episodeNumber: 2, title: 'Ep 2' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' }
]);
});
it('matches when titleLower is included in record title', () => {
const records = [
{ title: 'Test Show S01E01 Extra', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } }
];
const result = DownloadAssembler.gatherEpisodes('test show s01e01', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' }
]);
});
it('deduplicates episodes by season and episode number', () => {
const records = [
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } },
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1 Duplicate' } },
{ title: 'Test Show S01E02', episode: { seasonNumber: 1, episodeNumber: 2, title: 'Ep 2' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' },
{ season: 1, episode: 2, title: 'Ep 2' }
]);
});
it('sorts episodes by season then episode', () => {
const records = [
{ title: 'Test Show S02E05', episode: { seasonNumber: 2, episodeNumber: 5, title: 'Ep 5' } },
{ title: 'Test Show S01E10', episode: { seasonNumber: 1, episodeNumber: 10, title: 'Ep 10' } },
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } },
{ title: 'Test Show S02E01', episode: { seasonNumber: 2, episodeNumber: 1, title: 'Ep 1' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' },
{ season: 1, episode: 10, title: 'Ep 10' },
{ season: 2, episode: 1, title: 'Ep 1' },
{ season: 2, episode: 5, title: 'Ep 5' }
]);
});
it('handles case insensitivity', () => {
const records = [
{ title: 'TEST SHOW S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' }
]);
});
it('skips records that cannot extract episode info', () => {
const records = [
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } },
{ title: 'Test Show No Episode' },
{ title: 'Test Show S01E02', episode: { seasonNumber: 1, episodeNumber: 2, title: 'Ep 2' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' },
{ season: 1, episode: 2, title: 'Ep 2' }
]);
});
it('handles records with missing title and sourceTitle', () => {
const records = [
{ episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([]);
});
it('keeps first occurrence when deduplicating', () => {
const records = [
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'First' } },
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Second' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'First' }
]);
});
it('handles multiple seasons', () => {
const records = [
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'S1E1' } },
{ title: 'Test Show S02E01', episode: { seasonNumber: 2, episodeNumber: 1, title: 'S2E1' } },
{ title: 'Test Show S03E01', episode: { seasonNumber: 3, episodeNumber: 1, title: 'S3E1' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'S1E1' },
{ season: 2, episode: 1, title: 'S2E1' },
{ season: 3, episode: 1, title: 'S3E1' }
]);
});
it('handles special characters in titles', () => {
const records = [
{ title: 'Test.Show.S01E01.HDTV.x264', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } }
];
const result = DownloadAssembler.gatherEpisodes('test.show.s01e01', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' }
]);
});
it('deduplicates across different record types', () => {
const records = [
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'From Title' } },
{ sourceTitle: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'From Source' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'From Title' }
]);
});
});
});