Extract DownloadAssembler service from dashboard routes
- 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:
+53
-149
@@ -11,103 +11,7 @@ const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const downloadClientRegistry = require('../utils/downloadClients');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
const TagMatcher = require('../services/TagMatcher');
|
||||
|
||||
|
||||
// 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;
|
||||
}
|
||||
const DownloadAssembler = require('../services/DownloadAssembler');
|
||||
|
||||
|
||||
// Track active dashboard clients.
|
||||
@@ -249,7 +153,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
const dlObj = {
|
||||
type: 'series',
|
||||
title: nzbName,
|
||||
coverArt: getCoverArt(series),
|
||||
coverArt: DownloadAssembler.getCoverArt(series),
|
||||
status: slotState.status,
|
||||
progress: slot.percentage,
|
||||
mb: slot.mb,
|
||||
@@ -258,17 +162,17 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
speed: slotState.speed,
|
||||
eta: slot.timeleft,
|
||||
seriesName: series.title,
|
||||
episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records),
|
||||
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrQueue.data.records),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
||||
};
|
||||
const issues = getImportIssues(sonarrMatch);
|
||||
const issues = DownloadAssembler.getImportIssues(sonarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = series.path || null;
|
||||
dlObj.arrLink = getSonarrLink(series);
|
||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
dlObj.arrQueueId = sonarrMatch.id;
|
||||
dlObj.arrType = 'sonarr';
|
||||
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
@@ -276,7 +180,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
||||
dlObj.arrContentType = 'episode';
|
||||
}
|
||||
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin);
|
||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -298,7 +202,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
const dlObj = {
|
||||
type: 'movie',
|
||||
title: nzbName,
|
||||
coverArt: getCoverArt(movie),
|
||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||
status: slotState.status,
|
||||
progress: slot.percentage,
|
||||
mb: slot.mb,
|
||||
@@ -312,12 +216,12 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
||||
};
|
||||
const issues = getImportIssues(radarrMatch);
|
||||
const issues = DownloadAssembler.getImportIssues(radarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = movie.path || null;
|
||||
dlObj.arrLink = getRadarrLink(movie);
|
||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
dlObj.arrQueueId = radarrMatch.id;
|
||||
dlObj.arrType = 'radarr';
|
||||
dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
@@ -325,7 +229,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
dlObj.arrContentId = radarrMatch.movieId || null;
|
||||
dlObj.arrContentType = 'movie';
|
||||
}
|
||||
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin);
|
||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -364,12 +268,12 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
const dlObj = {
|
||||
type: 'series',
|
||||
title: nzbName,
|
||||
coverArt: getCoverArt(series),
|
||||
coverArt: DownloadAssembler.getCoverArt(series),
|
||||
status: slot.status,
|
||||
size: slot.size,
|
||||
completedAt: slot.completed_time,
|
||||
seriesName: series.title,
|
||||
episodes: gatherEpisodes(nzbNameLower, sonarrHistory.data.records),
|
||||
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistory.data.records),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
||||
@@ -377,7 +281,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = series.path || null;
|
||||
dlObj.arrLink = getSonarrLink(series);
|
||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
}
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
@@ -400,7 +304,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
const dlObj = {
|
||||
type: 'movie',
|
||||
title: nzbName,
|
||||
coverArt: getCoverArt(movie),
|
||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||
status: slot.status,
|
||||
size: slot.size,
|
||||
completedAt: slot.completed_time,
|
||||
@@ -413,7 +317,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = movie.path || null;
|
||||
dlObj.arrLink = getRadarrLink(movie);
|
||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
}
|
||||
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}"`);
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.type = 'series';
|
||||
download.coverArt = getCoverArt(series);
|
||||
download.coverArt = DownloadAssembler.getCoverArt(series);
|
||||
download.seriesName = series.title;
|
||||
download.episodes = gatherEpisodes(torrentNameLower, sonarrQueue.data.records);
|
||||
download.episodes = DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrQueue.data.records);
|
||||
download.allTags = allTags;
|
||||
download.matchedUserTag = matchedUserTag || null;
|
||||
download.tagBadges = showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined;
|
||||
const sonarrIssues = getImportIssues(sonarrMatch);
|
||||
const sonarrIssues = DownloadAssembler.getImportIssues(sonarrMatch);
|
||||
if (sonarrIssues) download.importIssues = sonarrIssues;
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = series.path || null;
|
||||
download.arrLink = getSonarrLink(series);
|
||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
download.arrQueueId = sonarrMatch.id;
|
||||
download.arrType = 'sonarr';
|
||||
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
@@ -489,7 +393,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
download.arrContentId = sonarrMatch.episodeId || null;
|
||||
download.arrContentType = 'episode';
|
||||
}
|
||||
download.canBlocklist = canBlocklist(download, isAdmin);
|
||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
userDownloads.push(download);
|
||||
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}"`);
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.type = 'movie';
|
||||
download.coverArt = getCoverArt(movie);
|
||||
download.coverArt = DownloadAssembler.getCoverArt(movie);
|
||||
download.movieName = movie.title;
|
||||
download.movieInfo = radarrMatch;
|
||||
download.allTags = allTags;
|
||||
download.matchedUserTag = matchedUserTag || null;
|
||||
download.tagBadges = showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined;
|
||||
const radarrIssues = getImportIssues(radarrMatch);
|
||||
const radarrIssues = DownloadAssembler.getImportIssues(radarrMatch);
|
||||
if (radarrIssues) download.importIssues = radarrIssues;
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = movie.path || null;
|
||||
download.arrLink = getRadarrLink(movie);
|
||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
download.arrQueueId = radarrMatch.id;
|
||||
download.arrType = 'radarr';
|
||||
download.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
@@ -531,7 +435,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
download.arrContentId = radarrMatch.movieId || null;
|
||||
download.arrContentType = 'movie';
|
||||
}
|
||||
download.canBlocklist = canBlocklist(download, isAdmin);
|
||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
userDownloads.push(download);
|
||||
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}"`);
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.type = 'series';
|
||||
download.coverArt = getCoverArt(series);
|
||||
download.coverArt = DownloadAssembler.getCoverArt(series);
|
||||
download.seriesName = series.title;
|
||||
download.episodes = gatherEpisodes(torrentNameLower, sonarrHistory.data.records);
|
||||
download.episodes = DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistory.data.records);
|
||||
download.allTags = allTags;
|
||||
download.matchedUserTag = matchedUserTag || null;
|
||||
download.tagBadges = showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined;
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = series.path || null;
|
||||
download.arrLink = getSonarrLink(series);
|
||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
}
|
||||
userDownloads.push(download);
|
||||
continue;
|
||||
@@ -587,7 +491,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr history "${movie.title}"`);
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.type = 'movie';
|
||||
download.coverArt = getCoverArt(movie);
|
||||
download.coverArt = DownloadAssembler.getCoverArt(movie);
|
||||
download.movieName = movie.title;
|
||||
download.movieInfo = radarrHistoryMatch;
|
||||
download.allTags = allTags;
|
||||
@@ -596,7 +500,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = movie.path || null;
|
||||
download.arrLink = getRadarrLink(movie);
|
||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
}
|
||||
userDownloads.push(download);
|
||||
continue;
|
||||
@@ -1039,11 +943,11 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
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 issues = getImportIssues(sonarrMatch);
|
||||
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 = DownloadAssembler.getImportIssues(sonarrMatch);
|
||||
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'; }
|
||||
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin);
|
||||
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 = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -1057,11 +961,11 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
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 issues = getImportIssues(radarrMatch);
|
||||
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 = DownloadAssembler.getImportIssues(radarrMatch);
|
||||
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'; }
|
||||
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin);
|
||||
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 = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -1086,8 +990,8 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
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' };
|
||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); }
|
||||
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 = DownloadAssembler.getSonarrLink(series); }
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -1103,8 +1007,8 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
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' };
|
||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); }
|
||||
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 = DownloadAssembler.getRadarrLink(movie); }
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
@@ -1126,10 +1030,10 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
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 });
|
||||
const issues = 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'; }
|
||||
download.canBlocklist = canBlocklist(download, isAdmin);
|
||||
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 = DownloadAssembler.getImportIssues(sonarrMatch); if (issues) download.importIssues = issues;
|
||||
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 = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
userDownloads.push(download); continue;
|
||||
}
|
||||
}
|
||||
@@ -1143,10 +1047,10 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
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 });
|
||||
const issues = 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'; }
|
||||
download.canBlocklist = canBlocklist(download, isAdmin);
|
||||
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 = DownloadAssembler.getImportIssues(radarrMatch); if (issues) download.importIssues = issues;
|
||||
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 = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
userDownloads.push(download); continue;
|
||||
}
|
||||
}
|
||||
@@ -1160,8 +1064,8 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
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 });
|
||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); }
|
||||
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 = DownloadAssembler.getSonarrLink(series); }
|
||||
userDownloads.push(download); continue;
|
||||
}
|
||||
}
|
||||
@@ -1175,8 +1079,8 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
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 });
|
||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); }
|
||||
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 = DownloadAssembler.getRadarrLink(movie); }
|
||||
userDownloads.push(download);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user