Files
sofarr/server/services/DownloadMatcher.js
T
gronod 87387aaebe
Build and Push Docker Image / build (push) Successful in 40s
CI / Security audit (push) Successful in 1m0s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m6s
CI / Swagger Validation & Coverage (push) Successful in 1m36s
CI / Tests & coverage (push) Has been cancelled
fix: resolve SABnzbd history matching asymmetry and unify search helpers (#74)
2026-05-29 14:35:02 +01:00

595 lines
20 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;
}
/**
* Internal helper: Finds the best matching Sonarr or Radarr record for a SABnzbd slot.
* Performs robust case-insensitive downloadId matching (queue → history),
* then bidirectional title fallback (queue → history).
* This eliminates all duplication and asymmetry between matchSabSlots and matchSabHistory.
*
* @param {string|null} sabDownloadId
* @param {string} nzbName
* @param {Object} context
* @param {string} caller - e.g. 'matchSabHistory' or 'matchSabSlots'
* @returns {{ sonarrMatch: Object|null, radarrMatch: Object|null }}
*/
function findSabMatch(sabDownloadId, nzbName, context, caller = 'DownloadMatcher') {
const {
sonarrQueueRecords = [],
sonarrHistoryRecords = [],
radarrQueueRecords = [],
radarrHistoryRecords = []
} = context;
const findBest = (queueRecords, historyRecords) => {
// 1. Robust ID match (queue first)
let match = sabDownloadId
? queueRecords.find(r => {
const dl = r && r.downloadId;
return dl && String(dl).toLowerCase() === String(sabDownloadId).toLowerCase();
})
: null;
if (!match && sabDownloadId) {
match = historyRecords.find(r => {
const dl = r && r.downloadId;
return dl && String(dl).toLowerCase() === String(sabDownloadId).toLowerCase();
});
}
// 2. Title fallback (queue first, then history)
if (!match && nzbName) {
match = queueRecords.find(r => {
const rTitle = r && (r.title || r.sourceTitle);
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller });
});
}
if (!match && nzbName) {
match = historyRecords.find(r => {
const rTitle = r && (r.title || r.sourceTitle);
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller });
});
}
return match || null;
};
return {
sonarrMatch: findBest(sonarrQueueRecords, sonarrHistoryRecords),
radarrMatch: findBest(radarrQueueRecords, radarrHistoryRecords)
};
}
/**
* 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.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.arrInstanceKey = record._instanceKey || 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();
const sabDownloadId = slot.nzo_id || slot.id;
const { sonarrMatch, radarrMatch } = findSabMatch(sabDownloadId, nzbName, context, '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;
}
async function matchSabHistory(slots, context) {
const matched = [];
for (const slot of slots) {
const nzbName = slot.name || slot.nzb_name || slot.nzbname;
if (!nzbName) continue;
const sabDownloadId = slot.nzo_id || slot.id;
const { sonarrMatch, radarrMatch } = findSabMatch(sabDownloadId, nzbName, context, 'matchSabHistory');
const commonOptions = {
title: nzbName,
status: slot.status || 'Completed',
progress: 100,
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(nzbName.toLowerCase(), context.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
};