Files
sofarr/server/services/DownloadMatcher.js
T
gronod 3c9dd3ca62
Build and Push Docker Image / build (push) Successful in 12s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 31s
CI / Security audit (push) Successful in 44s
CI / Tests & coverage (push) Failing after 53s
Fix: Remove unmatched torrents from download display
Downloads with no match in Sonarr or Radarr were being displayed
when they should not be. Removed the code in matchTorrents that
was adding unmatched torrents to results.
2026-05-21 00:37:37 +01:00

546 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');
/**
* 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;
}
/**
* 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
*/
function matchSabSlots(slots, context) {
const {
sonarrQueueRecords,
sonarrHistoryRecords,
radarrQueueRecords,
radarrHistoryRecords,
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
username,
isAdmin,
showAll,
embyUserMap,
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();
// 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;
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 || '').toLowerCase();
return rTitle && (
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
);
});
}
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)
);
});
}
// 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)
);
});
}
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)
);
});
}
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: slotState.status,
progress: slot.percentage,
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;
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);
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) {
const dlObj = {
type: 'movie',
title: nzbName,
coverArt: DownloadAssembler.getCoverArt(movie),
status: slotState.status,
progress: slot.percentage,
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 = 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);
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
*/
function matchSabHistory(slots, context) {
const {
sonarrHistoryRecords,
radarrHistoryRecords,
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
username,
isAdmin,
showAll,
embyUserMap
} = context;
const matched = [];
for (const slot of slots) {
const nzbName = slot.name || slot.nzb_name || slot.nzbname;
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,
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);
}
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,
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);
}
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
*/
function matchTorrents(torrents, context) {
const {
sonarrQueueRecords,
sonarrHistoryRecords,
radarrQueueRecords,
radarrHistoryRecords,
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
username,
isAdmin,
showAll,
embyUserMap
} = context;
const matched = [];
for (const torrent of torrents) {
const torrentName = torrent.name || '';
if (!torrentName) continue;
const torrentNameLower = torrentName.toLowerCase();
let matchedAny = false;
const 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;
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);
matched.push(download);
matchedAny = true;
continue;
}
}
}
const 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;
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);
matched.push(download);
matchedAny = true;
continue;
}
}
}
const 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);
}
matched.push(download);
matchedAny = true;
continue;
}
}
}
const 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);
}
matched.push(download);
matchedAny = true;
}
}
}
}
return matched;
}
module.exports = {
buildSeriesMapFromRecords,
buildMoviesMapFromRecords,
getSlotStatusAndSpeed,
matchSabSlots,
matchSabHistory,
matchTorrents
};