Extract matching logic into new DownloadMatcher service
Some checks failed
Some checks failed
This commit is contained in:
@@ -6,567 +6,7 @@
|
||||
* a unified view of downloads for each user, matching downloads to media metadata via tags.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no match found, still include the torrent for canBlocklist test
|
||||
// This handles the case where unmatched torrents should be visible
|
||||
if (!matchedAny) {
|
||||
let download;
|
||||
// Check if torrent already has the expected format (test data format)
|
||||
if (torrent.hash && torrent.progress && torrent.addedOn && torrent.availability) {
|
||||
download = {
|
||||
id: torrent.hash,
|
||||
hash: torrent.hash,
|
||||
title: torrent.name,
|
||||
progress: torrent.progress,
|
||||
speed: torrent.dlspeed || 0,
|
||||
addedOn: torrent.addedOn,
|
||||
availability: torrent.availability,
|
||||
qbittorrent: true,
|
||||
client: 'qbittorrent'
|
||||
};
|
||||
} else {
|
||||
// Use mapTorrentToDownload for raw qBittorrent API format
|
||||
download = mapTorrentToDownload(torrent);
|
||||
download.id = download.hash || torrent.hash;
|
||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||
download.qbittorrent = download.qbittorrent || torrent.qbittorrent || true;
|
||||
download.addedOn = download.addedOn || torrent.addedOn || torrent.added_on;
|
||||
const parsedAvail = parseFloat(download.availability);
|
||||
if (isNaN(parsedAvail) && torrent.availability) {
|
||||
download.availability = torrent.availability;
|
||||
}
|
||||
}
|
||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
matched.push(download);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
const DownloadMatcher = require('./DownloadMatcher');
|
||||
|
||||
/**
|
||||
* Builds a unified list of downloads for a user from multiple download clients.
|
||||
@@ -630,7 +70,7 @@ function buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmi
|
||||
const seenDownloadKeys = new Set();
|
||||
|
||||
if (sabnzbdQueue.data?.queue?.slots) {
|
||||
const sabMatches = matchSabSlots(sabnzbdQueue.data.queue.slots, context);
|
||||
const sabMatches = DownloadMatcher.matchSabSlots(sabnzbdQueue.data.queue.slots, context);
|
||||
for (const dl of sabMatches) {
|
||||
const key = `${dl.type}:${dl.title}`;
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
@@ -641,7 +81,7 @@ function buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmi
|
||||
}
|
||||
|
||||
if (sabnzbdHistory.data?.history?.slots) {
|
||||
const sabHistoryMatches = matchSabHistory(sabnzbdHistory.data.history.slots, context);
|
||||
const sabHistoryMatches = DownloadMatcher.matchSabHistory(sabnzbdHistory.data.history.slots, context);
|
||||
for (const dl of sabHistoryMatches) {
|
||||
const key = `${dl.type}:${dl.title}`;
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
@@ -651,7 +91,7 @@ function buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmi
|
||||
}
|
||||
}
|
||||
|
||||
const torrentMatches = matchTorrents(qbittorrentTorrents, context);
|
||||
const torrentMatches = DownloadMatcher.matchTorrents(qbittorrentTorrents, context);
|
||||
for (const dl of torrentMatches) {
|
||||
const key = `${dl.type}:${dl.title}`;
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
|
||||
578
server/services/DownloadMatcher.js
Normal file
578
server/services/DownloadMatcher.js
Normal file
@@ -0,0 +1,578 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no match found, still include the torrent for canBlocklist test
|
||||
// This handles the case where unmatched torrents should be visible
|
||||
if (!matchedAny) {
|
||||
let download;
|
||||
// Check if torrent already has the expected format (test data format)
|
||||
if (torrent.hash && torrent.progress && torrent.addedOn && torrent.availability) {
|
||||
download = {
|
||||
id: torrent.hash,
|
||||
hash: torrent.hash,
|
||||
title: torrent.name,
|
||||
progress: torrent.progress,
|
||||
speed: torrent.dlspeed || 0,
|
||||
addedOn: torrent.addedOn,
|
||||
availability: torrent.availability,
|
||||
qbittorrent: true,
|
||||
client: 'qbittorrent'
|
||||
};
|
||||
} else {
|
||||
// Use mapTorrentToDownload for raw qBittorrent API format
|
||||
download = mapTorrentToDownload(torrent);
|
||||
download.id = download.hash || torrent.hash;
|
||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||
download.qbittorrent = download.qbittorrent || torrent.qbittorrent || true;
|
||||
download.addedOn = download.addedOn || torrent.addedOn || torrent.added_on;
|
||||
const parsedAvail = parseFloat(download.availability);
|
||||
if (isNaN(parsedAvail) && torrent.availability) {
|
||||
download.availability = torrent.availability;
|
||||
}
|
||||
}
|
||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
matched.push(download);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildSeriesMapFromRecords,
|
||||
buildMoviesMapFromRecords,
|
||||
getSlotStatusAndSpeed,
|
||||
matchSabSlots,
|
||||
matchSabHistory,
|
||||
matchTorrents
|
||||
};
|
||||
Reference in New Issue
Block a user