0364a3c824
Build and Push Docker Image / build (push) Successful in 1m42s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m8s
CI / Security audit (push) Successful in 2m34s
CI / Swagger Validation & Coverage (push) Successful in 2m47s
CI / Tests & coverage (push) Failing after 2m51s
616 lines
21 KiB
JavaScript
616 lines
21 KiB
JavaScript
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
|
|
/**
|
|
* DownloadMatcher - Matches download client data to Sonarr/Radarr activity.
|
|
* Contains logic for matching SABnzbd slots and qBittorrent torrents to media metadata
|
|
* via download IDs and title matching.
|
|
*/
|
|
|
|
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, caller = 'DownloadMatcher' } = {}) {
|
|
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 in ${caller} 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.
|
|
*
|
|
* @example
|
|
* buildArrDownload(sonarrMatch, context, { client: 'sabnzbd', instanceId: 'sabnzbd-default', instanceName: 'SABnzbd' })
|
|
*/
|
|
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.
|
|
* @param {Array} queueRecords - Sonarr queue records
|
|
* @param {Array} historyRecords - Sonarr history records
|
|
* @returns {Map} Map of seriesId to series object
|
|
*/
|
|
function buildSeriesMapFromRecords(queueRecords, historyRecords) {
|
|
const seriesMap = new Map();
|
|
for (const r of queueRecords) {
|
|
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
|
}
|
|
for (const r of historyRecords) {
|
|
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
|
|
}
|
|
return seriesMap;
|
|
}
|
|
|
|
/**
|
|
* Builds a Map of movie metadata from Radarr queue and history records.
|
|
* @param {Array} queueRecords - Radarr queue records
|
|
* @param {Array} historyRecords - Radarr history records
|
|
* @returns {Map} Map of movieId to movie object
|
|
*/
|
|
function buildMoviesMapFromRecords(queueRecords, historyRecords) {
|
|
const moviesMap = new Map();
|
|
for (const r of queueRecords) {
|
|
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
|
}
|
|
for (const r of historyRecords) {
|
|
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
|
|
}
|
|
return moviesMap;
|
|
}
|
|
|
|
/**
|
|
* Adds an Ombi details link to a download object using the TMDB ID from the *arr media object.
|
|
* No Ombi API call is required — the link is built directly from the TMDB ID.
|
|
* @param {Object} downloadObj - Download object to enhance
|
|
* @param {Object} seriesOrMovie - Series or movie object from Sonarr/Radarr
|
|
* @param {Object} context - Context containing ombiBaseUrl
|
|
*/
|
|
function addOmbiMatching(downloadObj, seriesOrMovie, context) {
|
|
const { ombiBaseUrl } = context;
|
|
const link = DownloadAssembler.getOmbiDetailsLink(seriesOrMovie, downloadObj.type, ombiBaseUrl);
|
|
if (link) {
|
|
downloadObj.ombiLink = link;
|
|
downloadObj.ombiTooltip = 'View in Ombi';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determines the status and speed for a SABnzbd slot based on queue state.
|
|
* @param {Object} slot - SABnzbd queue slot
|
|
* @param {string} queueStatus - Overall queue status (e.g., 'Paused')
|
|
* @param {string} queueSpeed - Queue speed string
|
|
* @param {string} queueKbpersec - Queue speed in KB/s
|
|
* @returns {Object} Object with status and speed properties
|
|
*/
|
|
function getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec) {
|
|
if (queueStatus === 'Paused') {
|
|
return { status: 'Paused', speed: '0' };
|
|
}
|
|
return {
|
|
status: slot.status || 'Unknown',
|
|
speed: queueSpeed || queueKbpersec || '0'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Matches SABnzbd queue slots to Sonarr/Radarr activity using download IDs and title matching.
|
|
* @param {Array} slots - SABnzbd queue slots
|
|
* @param {Object} context - Matching context with records, maps, and user info
|
|
* @returns {Array} Array of matched download objects
|
|
*/
|
|
async function matchSabSlots(slots, context) {
|
|
const {
|
|
sonarrQueueRecords,
|
|
sonarrHistoryRecords,
|
|
radarrQueueRecords,
|
|
radarrHistoryRecords,
|
|
queueStatus,
|
|
queueSpeed,
|
|
queueKbpersec
|
|
} = context;
|
|
|
|
const matched = [];
|
|
for (const slot of slots) {
|
|
const nzbName = slot.filename || slot.nzbname;
|
|
if (!nzbName) continue;
|
|
|
|
const slotState = getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec);
|
|
const nzbNameLower = nzbName.toLowerCase();
|
|
|
|
// 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;
|
|
let radarrMatch = sabDownloadId ? radarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
|
|
|
|
// Also check HISTORY by downloadId
|
|
if (!sonarrMatch && sabDownloadId) {
|
|
sonarrMatch = sonarrHistoryRecords.find(r => r.downloadId === sabDownloadId);
|
|
}
|
|
if (!radarrMatch && sabDownloadId) {
|
|
radarrMatch = radarrHistoryRecords.find(r => r.downloadId === sabDownloadId);
|
|
}
|
|
|
|
// Fallback: Check by title matching
|
|
if (!sonarrMatch) {
|
|
sonarrMatch = sonarrQueueRecords.find(r => {
|
|
const rTitle = r.title || r.sourceTitle;
|
|
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' });
|
|
});
|
|
}
|
|
if (!radarrMatch) {
|
|
radarrMatch = radarrQueueRecords.find(r => {
|
|
const rTitle = r.title || r.sourceTitle;
|
|
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' });
|
|
});
|
|
}
|
|
|
|
// Also check HISTORY (completed downloads) if no queue match
|
|
if (!sonarrMatch) {
|
|
sonarrMatch = sonarrHistoryRecords.find(r => {
|
|
const rTitle = r.title || r.sourceTitle;
|
|
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' });
|
|
});
|
|
}
|
|
if (!radarrMatch) {
|
|
radarrMatch = radarrHistoryRecords.find(r => {
|
|
const rTitle = r.title || r.sourceTitle;
|
|
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' });
|
|
});
|
|
}
|
|
|
|
// 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 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 dlObj = buildArrDownload(radarrMatch, context, {
|
|
...commonOptions,
|
|
arrType: 'radarr'
|
|
});
|
|
if (dlObj) matched.push(dlObj);
|
|
}
|
|
}
|
|
return matched;
|
|
}
|
|
|
|
/**
|
|
* Matches SABnzbd history slots to Sonarr/Radarr activity using title matching.
|
|
* @param {Array} slots - SABnzbd history slots
|
|
* @param {Object} context - Matching context with records, maps, and user info
|
|
* @returns {Array} Array of matched download objects
|
|
*/
|
|
async function matchSabHistory(slots, context) {
|
|
const {
|
|
sonarrQueueRecords,
|
|
sonarrHistoryRecords,
|
|
radarrQueueRecords,
|
|
radarrHistoryRecords
|
|
} = context;
|
|
|
|
const matched = [];
|
|
for (const slot of slots) {
|
|
const nzbName = slot.name || slot.nzb_name || slot.nzbname;
|
|
if (!nzbName) continue;
|
|
const nzbNameLower = nzbName.toLowerCase();
|
|
|
|
// 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, caller: 'matchSabHistory' });
|
|
});
|
|
}
|
|
if (!radarrMatch) {
|
|
radarrMatch = radarrHistoryRecords.find(r => {
|
|
const rTitle = r.title || r.sourceTitle;
|
|
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabHistory' });
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
if (radarrMatch && radarrMatch.movieId) {
|
|
const dlObj = buildArrDownload(radarrMatch, context, {
|
|
...commonOptions,
|
|
arrType: 'radarr'
|
|
});
|
|
if (dlObj) matched.push(dlObj);
|
|
}
|
|
}
|
|
return matched;
|
|
}
|
|
|
|
/**
|
|
* Matches qBittorrent torrents to Sonarr/Radarr activity using title matching.
|
|
* @param {Array} torrents - qBittorrent torrent list
|
|
* @param {Object} context - Matching context with records, maps, and user info
|
|
* @returns {Array} Array of matched download objects
|
|
*/
|
|
async function matchTorrents(torrents, context) {
|
|
const {
|
|
sonarrQueueRecords,
|
|
sonarrHistoryRecords,
|
|
radarrQueueRecords,
|
|
radarrHistoryRecords
|
|
} = context;
|
|
|
|
const matched = [];
|
|
for (const torrent of torrents) {
|
|
const torrentName = torrent.name || '';
|
|
if (!torrentName) continue;
|
|
const torrentNameLower = torrentName.toLowerCase();
|
|
|
|
// Hash-first matching (Issue #65)
|
|
const torrentHash = torrent?.hash || torrent?.hashString || null;
|
|
const hashLower = torrentHash ? String(torrentHash).toLowerCase() : null;
|
|
const matchesByHash = (r) => {
|
|
const dl = r && r.downloadId;
|
|
if (!dl || !hashLower) return false;
|
|
return String(dl).toLowerCase() === hashLower;
|
|
};
|
|
|
|
let sonarrMatch = hashLower ? sonarrQueueRecords.find(matchesByHash) : null;
|
|
let radarrMatch = hashLower ? radarrQueueRecords.find(matchesByHash) : null;
|
|
|
|
// Fallback: Check by title matching
|
|
if (!sonarrMatch) {
|
|
sonarrMatch = sonarrQueueRecords.find(r => {
|
|
const rTitle = r.title || r.sourceTitle;
|
|
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' });
|
|
});
|
|
}
|
|
if (!radarrMatch) {
|
|
radarrMatch = radarrQueueRecords.find(r => {
|
|
const rTitle = r.title || r.sourceTitle;
|
|
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' });
|
|
});
|
|
}
|
|
|
|
// Fallback to history matching
|
|
let sonarrHistoryMatch = hashLower ? sonarrHistoryRecords.find(matchesByHash) : null;
|
|
let radarrHistoryMatch = hashLower ? radarrHistoryRecords.find(matchesByHash) : null;
|
|
|
|
if (!sonarrHistoryMatch) {
|
|
sonarrHistoryMatch = sonarrHistoryRecords.find(r => {
|
|
const rTitle = r.title || r.sourceTitle;
|
|
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' });
|
|
});
|
|
}
|
|
if (!radarrHistoryMatch) {
|
|
radarrHistoryMatch = radarrHistoryRecords.find(r => {
|
|
const rTitle = r.title || r.sourceTitle;
|
|
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' });
|
|
});
|
|
}
|
|
|
|
// 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)
|
|
const seen = new Set();
|
|
const deduped = [];
|
|
for (const m of matched) {
|
|
const key = (m && m.arrType && m.arrQueueId != null)
|
|
? `${m.arrType}:${m.arrQueueId}`
|
|
: null;
|
|
if (key) {
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
}
|
|
deduped.push(m);
|
|
}
|
|
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,
|
|
getSlotStatusAndSpeed,
|
|
addOmbiMatching,
|
|
matchSabSlots,
|
|
matchSabHistory,
|
|
matchTorrents,
|
|
buildArrDownload,
|
|
matchOrphanedArrRecords
|
|
};
|