fix: support orphaned *arr queue items and improve download matching reliability (#73)
Build and Push Docker Image / build (push) Successful in 1m55s
Docs Check / Markdown lint (push) Successful in 2m14s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m40s
CI / Security audit (push) Successful in 3m10s
CI / Swagger Validation & Coverage (push) Successful in 3m31s
Docs Check / Mermaid diagram parse check (push) Successful in 3m48s
CI / Tests & coverage (push) Failing after 4m7s
Build and Push Docker Image / build (push) Successful in 1m55s
Docs Check / Markdown lint (push) Successful in 2m14s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m40s
CI / Security audit (push) Successful in 3m10s
CI / Swagger Validation & Coverage (push) Successful in 3m31s
Docs Check / Mermaid diagram parse check (push) Successful in 3m48s
CI / Tests & coverage (push) Failing after 4m7s
This commit is contained in:
@@ -528,6 +528,16 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
type: c.getClientType()
|
||||
}));
|
||||
|
||||
// Append orphaned synthetic client entry if orphaned downloads exist
|
||||
const hasOrphaned = userDownloads.some(dl => dl && dl.isOrphaned);
|
||||
if (hasOrphaned && !downloadClients.some(c => c.id === 'orphaned')) {
|
||||
downloadClients.push({
|
||||
id: 'orphaned',
|
||||
name: 'Orphaned (unconfigured client)',
|
||||
type: 'orphaned'
|
||||
});
|
||||
}
|
||||
|
||||
// Filter Ombi requests by user if not admin or if showAll is false
|
||||
const ombiRequests = snapshot.ombiRequests || { movie: [], tv: [] };
|
||||
const showAllOmbi = showAll; // Use the same showAll flag for Ombi
|
||||
|
||||
@@ -72,6 +72,7 @@ async function buildUserDownloads(cacheSnapshot, { username, usernameSanitized,
|
||||
// Match all download sources
|
||||
const userDownloads = [];
|
||||
const seenDownloadKeys = new Set();
|
||||
const matchedArrQueueIds = new Set();
|
||||
|
||||
if (sabnzbdQueue.data?.queue?.slots) {
|
||||
const sabMatches = await DownloadMatcher.matchSabSlots(sabnzbdQueue.data.queue.slots, context);
|
||||
@@ -80,6 +81,7 @@ async function buildUserDownloads(cacheSnapshot, { username, usernameSanitized,
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
seenDownloadKeys.add(key);
|
||||
userDownloads.push(dl);
|
||||
if (dl.arrQueueId) matchedArrQueueIds.add(dl.arrQueueId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,12 +93,24 @@ async function buildUserDownloads(cacheSnapshot, { username, usernameSanitized,
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
seenDownloadKeys.add(key);
|
||||
userDownloads.push(dl);
|
||||
if (dl.arrQueueId) matchedArrQueueIds.add(dl.arrQueueId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const torrentMatches = await DownloadMatcher.matchTorrents(qbittorrentTorrents, context);
|
||||
for (const dl of torrentMatches) {
|
||||
const key = `${dl.type}:${dl.title}`;
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
seenDownloadKeys.add(key);
|
||||
userDownloads.push(dl);
|
||||
if (dl.arrQueueId) matchedArrQueueIds.add(dl.arrQueueId);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Match orphaned records that have no active download client counterpart
|
||||
const orphanedMatches = await DownloadMatcher.matchOrphanedArrRecords(matchedArrQueueIds, context);
|
||||
for (const dl of orphanedMatches) {
|
||||
const key = `${dl.type}:${dl.title}`;
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
seenDownloadKeys.add(key);
|
||||
|
||||
+380
-413
@@ -9,6 +9,137 @@
|
||||
const { mapTorrentToDownload } = require('../utils/qbittorrent');
|
||||
const TagMatcher = require('./TagMatcher');
|
||||
const DownloadAssembler = require('./DownloadAssembler');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
|
||||
const logger = {
|
||||
debug: (msg) => logToFile(`[DEBUG] ${msg}`)
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes a title by lowercase conversion and replacing periods/underscores/dashes with spaces.
|
||||
* @param {string} str - The title to normalize
|
||||
* @returns {string} Normalized title
|
||||
*/
|
||||
function normalizeTitle(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.toLowerCase()
|
||||
.replace(/\./g, ' ')
|
||||
.replace(/[\-_]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares a download client item name with a *arr title by checking both raw
|
||||
* and normalized (dots/dashes/underscores to spaces) forms bidirectionally.
|
||||
* Only logs on title fallback matches (when isFallback=true) to keep logs clean.
|
||||
*/
|
||||
function titleMatches(clientName, arrTitle, { isFallback = true } = {}) {
|
||||
if (!clientName || !arrTitle) return false;
|
||||
const a = clientName.toLowerCase();
|
||||
const b = arrTitle.toLowerCase();
|
||||
const aNorm = normalizeTitle(clientName);
|
||||
const bNorm = normalizeTitle(arrTitle);
|
||||
|
||||
const matched = a.includes(b) || b.includes(a) ||
|
||||
aNorm.includes(bNorm) || bNorm.includes(aNorm) ||
|
||||
aNorm.includes(b) || b.includes(aNorm) ||
|
||||
a.includes(bNorm) || bNorm.includes(a);
|
||||
|
||||
if (matched && isFallback) {
|
||||
logger.debug(`[DownloadMatcher] Title fallback match after normalization: "${clientName}" <-> "${arrTitle}"`);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* All callers (matchers + orphan path) must supply client, instanceId, and instanceName via options.
|
||||
* Defaults exist only as a last-resort safety net.
|
||||
*/
|
||||
function buildArrDownload(record, context, options = {}) {
|
||||
const {
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap
|
||||
} = context;
|
||||
|
||||
// Detect if sonarr or radarr record
|
||||
const isSeries = record.seriesId !== undefined || options.arrType === 'sonarr';
|
||||
const mediaMap = isSeries ? seriesMap : moviesMap;
|
||||
const tagMap = isSeries ? sonarrTagMap : radarrTagMap;
|
||||
const mediaId = isSeries ? record.seriesId : record.movieId;
|
||||
|
||||
const media = mediaMap.get(mediaId) || record.series || record.movie;
|
||||
if (!media) return null;
|
||||
|
||||
// Tag-based user filtering
|
||||
const allTags = TagMatcher.extractAllTags(media.tags, tagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(media.tags, tagMap, username);
|
||||
if (!showAll && !matchedUserTag) return null;
|
||||
|
||||
// Safer default progress of 0 for items that haven't started yet
|
||||
const progress = options.progress !== undefined ? options.progress : 0;
|
||||
|
||||
const dlObj = {
|
||||
type: isSeries ? 'series' : 'movie',
|
||||
title: options.title || record.title || record.sourceTitle,
|
||||
coverArt: DownloadAssembler.getCoverArt(media),
|
||||
status: options.status || record.status || 'Unknown',
|
||||
progress,
|
||||
mb: options.mb !== undefined ? options.mb : (record.size ? Math.round(record.size / 1024 / 1024) : 0),
|
||||
size: options.size !== undefined ? options.size : (record.size || 0),
|
||||
completedAt: options.completedAt || record.completed_time || null,
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
// Strict neutral defaults to avoid incorrect SABnzbd-centric data
|
||||
client: options.client || 'orphaned',
|
||||
instanceId: options.instanceId || 'orphaned',
|
||||
instanceName: options.instanceName || 'Unknown',
|
||||
...options.overrides
|
||||
};
|
||||
|
||||
if (isSeries) {
|
||||
dlObj.seriesName = media.title;
|
||||
dlObj.episodes = options.episodes || [];
|
||||
} else {
|
||||
dlObj.movieName = media.title;
|
||||
dlObj.movieInfo = record;
|
||||
}
|
||||
|
||||
const issues = DownloadAssembler.getImportIssues(record);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
|
||||
dlObj.arrQueueId = record.id;
|
||||
dlObj.arrType = isSeries ? 'sonarr' : 'radarr';
|
||||
dlObj.arrInstanceUrl = record._instanceUrl || null;
|
||||
dlObj.arrInstanceKey = record._instanceKey || null;
|
||||
dlObj.arrContentId = record.episodeId || record.movieId || null;
|
||||
dlObj.arrContentIds = record.episodeIds || null;
|
||||
dlObj.arrSeriesId = record.seriesId || null;
|
||||
dlObj.arrContentType = isSeries ? 'episode' : 'movie';
|
||||
|
||||
// Use correct blocklist determination
|
||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = options.downloadPath || null;
|
||||
dlObj.targetPath = media.path || null;
|
||||
dlObj.arrLink = isSeries
|
||||
? DownloadAssembler.getSonarrLink(media)
|
||||
: DownloadAssembler.getRadarrLink(media);
|
||||
}
|
||||
|
||||
addOmbiMatching(dlObj, media, context);
|
||||
|
||||
return dlObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Map of series metadata from Sonarr queue and history records.
|
||||
@@ -90,19 +221,9 @@ async function matchSabSlots(slots, context) {
|
||||
sonarrHistoryRecords,
|
||||
radarrQueueRecords,
|
||||
radarrHistoryRecords,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap,
|
||||
queueStatus,
|
||||
queueSpeed,
|
||||
queueKbpersec,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
queueKbpersec
|
||||
} = context;
|
||||
|
||||
const matched = [];
|
||||
@@ -113,9 +234,6 @@ async function matchSabSlots(slots, context) {
|
||||
const slotState = getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec);
|
||||
const nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
// Normalize SAB name (dots to spaces) for better matching
|
||||
const nzbNameNormalized = nzbNameLower.replace(/\./g, ' ');
|
||||
|
||||
// Try to match by downloadId first (most reliable)
|
||||
const sabDownloadId = slot.nzo_id || slot.id;
|
||||
let sonarrMatch = sabDownloadId ? sonarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
|
||||
@@ -132,157 +250,70 @@ async function matchSabSlots(slots, context) {
|
||||
// Fallback: Check by title matching
|
||||
if (!sonarrMatch) {
|
||||
sonarrMatch = sonarrQueueRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (
|
||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||
);
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
if (!radarrMatch) {
|
||||
radarrMatch = radarrQueueRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (
|
||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||
);
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Also check HISTORY (completed downloads) if no queue match
|
||||
if (!sonarrMatch) {
|
||||
sonarrMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (
|
||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||
);
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
if (!radarrMatch) {
|
||||
radarrMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (
|
||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||
);
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
// Calculate progress from SABnzbd slot data
|
||||
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
|
||||
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
|
||||
? parseFloat(slot.mbleft || slot.mbmissing)
|
||||
: 0;
|
||||
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
|
||||
// Progress calculation
|
||||
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
|
||||
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
|
||||
? parseFloat(slot.mbleft || slot.mbmissing)
|
||||
: 0;
|
||||
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
|
||||
|
||||
const dlObj = {
|
||||
type: 'series',
|
||||
title: nzbName,
|
||||
coverArt: DownloadAssembler.getCoverArt(series),
|
||||
status: slotState.status,
|
||||
progress: Math.round(progress),
|
||||
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, sonarrQueueRecords),
|
||||
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;
|
||||
// Expose ARR IDs to non-admins for blocklist functionality
|
||||
dlObj.arrQueueId = sonarrMatch.id;
|
||||
dlObj.arrType = 'sonarr';
|
||||
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
||||
dlObj.arrContentIds = sonarrMatch.episodeIds || null;
|
||||
dlObj.arrSeriesId = sonarrMatch.seriesId || null;
|
||||
dlObj.arrContentType = 'episode';
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = series.path || null;
|
||||
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
||||
series._instanceUrl = sonarrMatch._instanceUrl;
|
||||
}
|
||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
}
|
||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
addOmbiMatching(dlObj, series, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
const commonOptions = {
|
||||
title: nzbName,
|
||||
status: slotState.status,
|
||||
progress: Math.round(progress),
|
||||
mb: slot.mb,
|
||||
size: Math.round(slot.mb * 1024 * 1024),
|
||||
client: 'sabnzbd',
|
||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||
instanceName: slot.instanceName || 'SABnzbd',
|
||||
downloadPath: slot.storage || null,
|
||||
overrides: {
|
||||
mbmissing: slot.mbleft,
|
||||
speed: Math.round((slot.kbpersec || 0) * 1024),
|
||||
eta: slot.timeleft
|
||||
}
|
||||
};
|
||||
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const dlObj = buildArrDownload(sonarrMatch, context, {
|
||||
...commonOptions,
|
||||
arrType: 'sonarr',
|
||||
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrQueueRecords)
|
||||
});
|
||||
if (dlObj) matched.push(dlObj);
|
||||
}
|
||||
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
// Calculate progress from SABnzbd slot data
|
||||
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
|
||||
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
|
||||
? parseFloat(slot.mbleft || slot.mbmissing)
|
||||
: 0;
|
||||
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
|
||||
|
||||
const dlObj = {
|
||||
type: 'movie',
|
||||
title: nzbName,
|
||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||
status: slotState.status,
|
||||
progress: Math.round(progress),
|
||||
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;
|
||||
// Expose ARR IDs to non-admins for blocklist functionality
|
||||
dlObj.arrQueueId = radarrMatch.id;
|
||||
dlObj.arrType = 'radarr';
|
||||
dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
dlObj.arrContentId = radarrMatch.movieId || null;
|
||||
dlObj.arrContentType = 'movie';
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = movie.path || null;
|
||||
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
||||
movie._instanceUrl = radarrMatch._instanceUrl;
|
||||
}
|
||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
}
|
||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
addOmbiMatching(dlObj, movie, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
const dlObj = buildArrDownload(radarrMatch, context, {
|
||||
...commonOptions,
|
||||
arrType: 'radarr'
|
||||
});
|
||||
if (dlObj) matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
@@ -296,18 +327,10 @@ async function matchSabSlots(slots, context) {
|
||||
*/
|
||||
async function matchSabHistory(slots, context) {
|
||||
const {
|
||||
sonarrQueueRecords,
|
||||
sonarrHistoryRecords,
|
||||
radarrHistoryRecords,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
radarrQueueRecords,
|
||||
radarrHistoryRecords
|
||||
} = context;
|
||||
|
||||
const matched = [];
|
||||
@@ -316,82 +339,67 @@ async function matchSabHistory(slots, context) {
|
||||
if (!nzbName) continue;
|
||||
const nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
const sonarrMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
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: DownloadAssembler.getCoverArt(series),
|
||||
status: slot.status,
|
||||
progress: 100, // History items are completed
|
||||
mb: slot.mb,
|
||||
size: Math.round((slot.mb || 0) * 1024 * 1024),
|
||||
completedAt: slot.completed_time,
|
||||
seriesName: series.title,
|
||||
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistoryRecords),
|
||||
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);
|
||||
}
|
||||
addOmbiMatching(dlObj, series, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
// Try to match by downloadId (nzo_id or slot ID) first (most reliable)
|
||||
const sabDownloadId = slot.nzo_id || slot.id;
|
||||
const matchesSabId = (r) => {
|
||||
const dl = r && r.downloadId;
|
||||
if (!dl || !sabDownloadId) return false;
|
||||
return String(dl).toLowerCase() === String(sabDownloadId).toLowerCase();
|
||||
};
|
||||
|
||||
let sonarrMatch = sabDownloadId ? sonarrHistoryRecords.find(matchesSabId) : null;
|
||||
let radarrMatch = sabDownloadId ? radarrHistoryRecords.find(matchesSabId) : null;
|
||||
|
||||
// Dual-lookup: also try against active queue records (history slot may still be in *arr queue)
|
||||
if (!sonarrMatch && sabDownloadId) {
|
||||
sonarrMatch = sonarrQueueRecords.find(matchesSabId);
|
||||
}
|
||||
if (!radarrMatch && sabDownloadId) {
|
||||
radarrMatch = radarrQueueRecords.find(matchesSabId);
|
||||
}
|
||||
|
||||
// Fallback: Check by title matching
|
||||
if (!sonarrMatch) {
|
||||
sonarrMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
if (!radarrMatch) {
|
||||
radarrMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
|
||||
const commonOptions = {
|
||||
title: nzbName,
|
||||
status: slot.status || 'Completed',
|
||||
progress: 100, // History items are completed
|
||||
mb: slot.mb,
|
||||
size: Math.round((slot.mb || 0) * 1024 * 1024),
|
||||
completedAt: slot.completed_time,
|
||||
client: 'sabnzbd',
|
||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||
instanceName: slot.instanceName || 'SABnzbd',
|
||||
downloadPath: slot.storage || null
|
||||
};
|
||||
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const dlObj = buildArrDownload(sonarrMatch, context, {
|
||||
...commonOptions,
|
||||
arrType: 'sonarr',
|
||||
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistoryRecords)
|
||||
});
|
||||
if (dlObj) matched.push(dlObj);
|
||||
}
|
||||
|
||||
const radarrMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
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: DownloadAssembler.getCoverArt(movie),
|
||||
status: slot.status,
|
||||
progress: 100, // History items are completed
|
||||
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);
|
||||
}
|
||||
addOmbiMatching(dlObj, movie, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
const dlObj = buildArrDownload(radarrMatch, context, {
|
||||
...commonOptions,
|
||||
arrType: 'radarr'
|
||||
});
|
||||
if (dlObj) matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
@@ -408,17 +416,7 @@ async function matchTorrents(torrents, context) {
|
||||
sonarrQueueRecords,
|
||||
sonarrHistoryRecords,
|
||||
radarrQueueRecords,
|
||||
radarrHistoryRecords,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
radarrHistoryRecords
|
||||
} = context;
|
||||
|
||||
const matched = [];
|
||||
@@ -427,12 +425,7 @@ async function matchTorrents(torrents, context) {
|
||||
if (!torrentName) continue;
|
||||
const torrentNameLower = torrentName.toLowerCase();
|
||||
|
||||
let matchedAny = false;
|
||||
|
||||
// Hash-first matching (Issue #65): prefer matching by torrent hash against
|
||||
// each *arr queue record's `downloadId`. `torrent.hash` covers qBittorrent
|
||||
// and rTorrent; `torrent.hashString` covers Transmission. We fall back to
|
||||
// existing title-substring matching only if no hash match was found.
|
||||
// Hash-first matching (Issue #65)
|
||||
const torrentHash = torrent?.hash || torrent?.hashString || null;
|
||||
const hashLower = torrentHash ? String(torrentHash).toLowerCase() : null;
|
||||
const matchesByHash = (r) => {
|
||||
@@ -442,183 +435,104 @@ async function matchTorrents(torrents, context) {
|
||||
};
|
||||
|
||||
let sonarrMatch = hashLower ? sonarrQueueRecords.find(matchesByHash) : null;
|
||||
if (!sonarrMatch) sonarrMatch = sonarrQueueRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.id = download.hash || torrent.hash;
|
||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||
Object.assign(download, {
|
||||
type: 'series',
|
||||
coverArt: DownloadAssembler.getCoverArt(series),
|
||||
seriesName: series.title,
|
||||
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrQueueRecords),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
||||
});
|
||||
const issues = DownloadAssembler.getImportIssues(sonarrMatch);
|
||||
if (issues) download.importIssues = issues;
|
||||
// Expose ARR IDs to non-admins for blocklist functionality
|
||||
download.arrQueueId = sonarrMatch.id;
|
||||
download.arrType = 'sonarr';
|
||||
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
download.arrContentId = sonarrMatch.episodeId || null;
|
||||
download.arrContentIds = sonarrMatch.episodeIds || null;
|
||||
download.arrSeriesId = sonarrMatch.seriesId || null;
|
||||
download.arrContentType = 'episode';
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = series.path || null;
|
||||
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
||||
series._instanceUrl = sonarrMatch._instanceUrl;
|
||||
}
|
||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
}
|
||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
addOmbiMatching(download, series, context);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let radarrMatch = hashLower ? radarrQueueRecords.find(matchesByHash) : null;
|
||||
if (!radarrMatch) radarrMatch = radarrQueueRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.id = download.hash || torrent.hash;
|
||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||
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;
|
||||
// Expose ARR IDs to non-admins for blocklist functionality
|
||||
download.arrQueueId = radarrMatch.id;
|
||||
download.arrType = 'radarr';
|
||||
download.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
download.arrContentId = radarrMatch.movieId || null;
|
||||
download.arrContentType = 'movie';
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = movie.path || null;
|
||||
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
||||
movie._instanceUrl = radarrMatch._instanceUrl;
|
||||
}
|
||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
}
|
||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
addOmbiMatching(download, movie, context);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Check by title matching
|
||||
if (!sonarrMatch) {
|
||||
sonarrMatch = sonarrQueueRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
if (!radarrMatch) {
|
||||
radarrMatch = radarrQueueRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback to history matching
|
||||
let sonarrHistoryMatch = hashLower ? sonarrHistoryRecords.find(matchesByHash) : null;
|
||||
if (!sonarrHistoryMatch) sonarrHistoryMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
|
||||
if (series) {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
Object.assign(download, {
|
||||
type: 'series',
|
||||
coverArt: DownloadAssembler.getCoverArt(series),
|
||||
seriesName: series.title,
|
||||
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistoryRecords),
|
||||
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);
|
||||
}
|
||||
addOmbiMatching(download, series, context);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let radarrHistoryMatch = hashLower ? radarrHistoryRecords.find(matchesByHash) : null;
|
||||
if (!radarrHistoryMatch) radarrHistoryMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.id = download.hash || torrent.hash;
|
||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||
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);
|
||||
}
|
||||
addOmbiMatching(download, movie, context);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sonarrHistoryMatch) {
|
||||
sonarrHistoryMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
if (!radarrHistoryMatch) {
|
||||
radarrHistoryMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Helper options for torrent mapping
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
const progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||
const speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||
|
||||
const commonOptions = {
|
||||
title: torrentName,
|
||||
status: download.status || torrent.status || 'Downloading',
|
||||
progress: Math.round(progress),
|
||||
mb: download.size ? Math.round(download.size / 1024 / 1024) : 0,
|
||||
size: download.size || torrent.size || 0,
|
||||
client: download.client || 'qbittorrent',
|
||||
instanceId: torrent.instanceId || download.instanceId || 'qbittorrent-default',
|
||||
instanceName: torrent.instanceName || download.instanceName || 'qBittorrent',
|
||||
downloadPath: download.savePath || torrent.savePath || null,
|
||||
overrides: {
|
||||
id: download.hash || torrent.hash,
|
||||
speed,
|
||||
eta: torrent.eta,
|
||||
seeds: torrent.seeds,
|
||||
peers: torrent.peers,
|
||||
availability: torrent.availability,
|
||||
addedOn: torrent.addedOn,
|
||||
qbittorrent: true
|
||||
}
|
||||
};
|
||||
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const dlObj = buildArrDownload(sonarrMatch, context, {
|
||||
...commonOptions,
|
||||
arrType: 'sonarr',
|
||||
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrQueueRecords)
|
||||
});
|
||||
if (dlObj) matched.push(dlObj);
|
||||
}
|
||||
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const dlObj = buildArrDownload(radarrMatch, context, {
|
||||
...commonOptions,
|
||||
arrType: 'radarr'
|
||||
});
|
||||
if (dlObj) matched.push(dlObj);
|
||||
}
|
||||
|
||||
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
|
||||
const dlObj = buildArrDownload(sonarrHistoryMatch, context, {
|
||||
...commonOptions,
|
||||
arrType: 'sonarr',
|
||||
progress: 100, // completed
|
||||
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistoryRecords)
|
||||
});
|
||||
if (dlObj) matched.push(dlObj);
|
||||
}
|
||||
|
||||
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
|
||||
const dlObj = buildArrDownload(radarrHistoryMatch, context, {
|
||||
...commonOptions,
|
||||
arrType: 'radarr',
|
||||
progress: 100 // completed
|
||||
});
|
||||
if (dlObj) matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by (arrType, arrQueueId) (Issue #65). When a single torrent
|
||||
// (typically a season pack) matches N *arr queue records sharing one
|
||||
// arrQueueId via downstream emission paths, only the first matched download
|
||||
// is retained. Entries without an arrQueueId pass through unchanged.
|
||||
// Deduplicate by (arrType, arrQueueId) (Issue #65)
|
||||
const seen = new Set();
|
||||
const deduped = [];
|
||||
for (const m of matched) {
|
||||
@@ -634,6 +548,57 @@ async function matchTorrents(torrents, context) {
|
||||
return deduped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches orphaned *arr queue items that have no corresponding download client item
|
||||
* but still reside in the active Sonarr/Radarr queue.
|
||||
* @param {Set<number>} matchedArrQueueIds - Already matched queue record IDs to skip
|
||||
* @param {Object} context - Matching context with records, maps, and user info
|
||||
* @returns {Array} Array of orphaned download objects
|
||||
*/
|
||||
function matchOrphanedArrRecords(matchedArrQueueIds, context) {
|
||||
const { sonarrQueueRecords, radarrQueueRecords } = context;
|
||||
const matched = [];
|
||||
|
||||
// Deduplication Strategy:
|
||||
// We initialize the processed Set with already-matched IDs compiled during Phase 1 matching.
|
||||
// We also track newly processed IDs locally to handle situations where multiple duplicate queue
|
||||
// records pointing to the same downloadId exist in Sonarr/Radarr.
|
||||
const processedQueueIds = new Set(matchedArrQueueIds);
|
||||
|
||||
const processRecords = (records, arrType) => {
|
||||
for (const record of records) {
|
||||
if (processedQueueIds.has(record.id)) continue;
|
||||
processedQueueIds.add(record.id);
|
||||
|
||||
// Safe progress arithmetic to prevent NaN or division-by-zero
|
||||
const size = record.size || 0;
|
||||
const sizeleft = record.sizeleft || 0;
|
||||
const progress = size > 0 ? Math.round(100 - (sizeleft / size * 100)) : 0;
|
||||
const status = record.trackedDownloadStatus || record.status || 'Unknown';
|
||||
|
||||
const dl = buildArrDownload(record, context, {
|
||||
arrType,
|
||||
status,
|
||||
progress,
|
||||
client: 'orphaned',
|
||||
instanceId: 'orphaned',
|
||||
instanceName: 'Orphaned (unconfigured client)',
|
||||
overrides: { isOrphaned: true }
|
||||
});
|
||||
|
||||
if (dl) {
|
||||
logger.debug(`[DownloadMatcher] Found orphaned *arr queue item: ${dl.title} (arrQueueId=${record.id})`);
|
||||
matched.push(dl);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processRecords(sonarrQueueRecords || [], 'sonarr');
|
||||
processRecords(radarrQueueRecords || [], 'radarr');
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildSeriesMapFromRecords,
|
||||
buildMoviesMapFromRecords,
|
||||
@@ -641,5 +606,7 @@ module.exports = {
|
||||
addOmbiMatching,
|
||||
matchSabSlots,
|
||||
matchSabHistory,
|
||||
matchTorrents
|
||||
matchTorrents,
|
||||
buildArrDownload,
|
||||
matchOrphanedArrRecords
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user