fix: support orphaned *arr queue items and improve download matching reliability (#73)
Build and Push Docker Image / build (push) Successful in 1m55s
Docs Check / Markdown lint (push) Successful in 2m14s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m40s
CI / Security audit (push) Successful in 3m10s
CI / Swagger Validation & Coverage (push) Successful in 3m31s
Docs Check / Mermaid diagram parse check (push) Successful in 3m48s
CI / Tests & coverage (push) Failing after 4m7s
Build and Push Docker Image / build (push) Successful in 1m55s
Docs Check / Markdown lint (push) Successful in 2m14s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m40s
CI / Security audit (push) Successful in 3m10s
CI / Swagger Validation & Coverage (push) Successful in 3m31s
Docs Check / Mermaid diagram parse check (push) Successful in 3m48s
CI / Tests & coverage (push) Failing after 4m7s
This commit is contained in:
@@ -35,12 +35,17 @@ export function renderTagBadges(tagBadges, showAll, matchedUserTag) {
|
||||
function createClientLogo(download) {
|
||||
const clientLogoWrapper = document.createElement('span');
|
||||
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
|
||||
if (download.isOrphaned) {
|
||||
clientLogoWrapper.classList.add('orphaned-logo');
|
||||
}
|
||||
|
||||
const clientLogo = document.createElement('img');
|
||||
clientLogo.className = 'download-client-logo';
|
||||
clientLogo.src = `/images/clients/${download.client}.svg`;
|
||||
clientLogo.alt = `${download.instanceName || download.client} icon`;
|
||||
clientLogo.title = download.instanceName || download.client;
|
||||
clientLogo.title = download.isOrphaned
|
||||
? "This download is managed by a *arr instance's download client that is not configured in Sofarr."
|
||||
: (download.instanceName || download.client);
|
||||
clientLogo.onerror = () => {
|
||||
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
|
||||
clientLogoWrapper.classList.add('fallback');
|
||||
@@ -303,7 +308,7 @@ export async function handleBlocklistSearchClick(btn, download) {
|
||||
|
||||
export function createDownloadCard(download) {
|
||||
const card = document.createElement('div');
|
||||
card.className = `download-card ${download.type}`;
|
||||
card.className = `download-card ${download.type}${download.isOrphaned ? ' orphaned' : ''}`;
|
||||
card.dataset.id = download.title;
|
||||
|
||||
// Cover art
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
|
||||
<circle cx="256" cy="256" r="240" fill="#f5f5f7" stroke="#d2d2d7" stroke-width="20"/>
|
||||
<text x="50%" y="60%" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" font-size="300px" font-weight="bold" fill="#86868b" text-anchor="middle" dominant-baseline="middle">?</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 409 B |
@@ -2419,3 +2419,14 @@ body {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
/* ===== Orphaned Download Styling ===== */
|
||||
.download-card.orphaned {
|
||||
border-left: 3px dashed var(--border-color, #c8c8cc);
|
||||
opacity: 0.95;
|
||||
}
|
||||
.download-client-logo-wrapper.orphaned-logo {
|
||||
filter: grayscale(1) opacity(0.5);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -528,6 +528,16 @@ router.get('/stream', requireAuth, async (req, res) => {
|
||||
type: c.getClientType()
|
||||
}));
|
||||
|
||||
// Append orphaned synthetic client entry if orphaned downloads exist
|
||||
const hasOrphaned = userDownloads.some(dl => dl && dl.isOrphaned);
|
||||
if (hasOrphaned && !downloadClients.some(c => c.id === 'orphaned')) {
|
||||
downloadClients.push({
|
||||
id: 'orphaned',
|
||||
name: 'Orphaned (unconfigured client)',
|
||||
type: 'orphaned'
|
||||
});
|
||||
}
|
||||
|
||||
// Filter Ombi requests by user if not admin or if showAll is false
|
||||
const ombiRequests = snapshot.ombiRequests || { movie: [], tv: [] };
|
||||
const showAllOmbi = showAll; // Use the same showAll flag for Ombi
|
||||
|
||||
@@ -72,6 +72,7 @@ async function buildUserDownloads(cacheSnapshot, { username, usernameSanitized,
|
||||
// Match all download sources
|
||||
const userDownloads = [];
|
||||
const seenDownloadKeys = new Set();
|
||||
const matchedArrQueueIds = new Set();
|
||||
|
||||
if (sabnzbdQueue.data?.queue?.slots) {
|
||||
const sabMatches = await DownloadMatcher.matchSabSlots(sabnzbdQueue.data.queue.slots, context);
|
||||
@@ -80,6 +81,7 @@ async function buildUserDownloads(cacheSnapshot, { username, usernameSanitized,
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
seenDownloadKeys.add(key);
|
||||
userDownloads.push(dl);
|
||||
if (dl.arrQueueId) matchedArrQueueIds.add(dl.arrQueueId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,12 +93,24 @@ async function buildUserDownloads(cacheSnapshot, { username, usernameSanitized,
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
seenDownloadKeys.add(key);
|
||||
userDownloads.push(dl);
|
||||
if (dl.arrQueueId) matchedArrQueueIds.add(dl.arrQueueId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const torrentMatches = await DownloadMatcher.matchTorrents(qbittorrentTorrents, context);
|
||||
for (const dl of torrentMatches) {
|
||||
const key = `${dl.type}:${dl.title}`;
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
seenDownloadKeys.add(key);
|
||||
userDownloads.push(dl);
|
||||
if (dl.arrQueueId) matchedArrQueueIds.add(dl.arrQueueId);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Match orphaned records that have no active download client counterpart
|
||||
const orphanedMatches = await DownloadMatcher.matchOrphanedArrRecords(matchedArrQueueIds, context);
|
||||
for (const dl of orphanedMatches) {
|
||||
const key = `${dl.type}:${dl.title}`;
|
||||
if (!seenDownloadKeys.has(key)) {
|
||||
seenDownloadKeys.add(key);
|
||||
|
||||
+380
-413
@@ -9,6 +9,137 @@
|
||||
const { mapTorrentToDownload } = require('../utils/qbittorrent');
|
||||
const TagMatcher = require('./TagMatcher');
|
||||
const DownloadAssembler = require('./DownloadAssembler');
|
||||
const { logToFile } = require('../utils/logger');
|
||||
|
||||
const logger = {
|
||||
debug: (msg) => logToFile(`[DEBUG] ${msg}`)
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes a title by lowercase conversion and replacing periods/underscores/dashes with spaces.
|
||||
* @param {string} str - The title to normalize
|
||||
* @returns {string} Normalized title
|
||||
*/
|
||||
function normalizeTitle(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.toLowerCase()
|
||||
.replace(/\./g, ' ')
|
||||
.replace(/[\-_]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares a download client item name with a *arr title by checking both raw
|
||||
* and normalized (dots/dashes/underscores to spaces) forms bidirectionally.
|
||||
* Only logs on title fallback matches (when isFallback=true) to keep logs clean.
|
||||
*/
|
||||
function titleMatches(clientName, arrTitle, { isFallback = true } = {}) {
|
||||
if (!clientName || !arrTitle) return false;
|
||||
const a = clientName.toLowerCase();
|
||||
const b = arrTitle.toLowerCase();
|
||||
const aNorm = normalizeTitle(clientName);
|
||||
const bNorm = normalizeTitle(arrTitle);
|
||||
|
||||
const matched = a.includes(b) || b.includes(a) ||
|
||||
aNorm.includes(bNorm) || bNorm.includes(aNorm) ||
|
||||
aNorm.includes(b) || b.includes(aNorm) ||
|
||||
a.includes(bNorm) || bNorm.includes(a);
|
||||
|
||||
if (matched && isFallback) {
|
||||
logger.debug(`[DownloadMatcher] Title fallback match after normalization: "${clientName}" <-> "${arrTitle}"`);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* All callers (matchers + orphan path) must supply client, instanceId, and instanceName via options.
|
||||
* Defaults exist only as a last-resort safety net.
|
||||
*/
|
||||
function buildArrDownload(record, context, options = {}) {
|
||||
const {
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap
|
||||
} = context;
|
||||
|
||||
// Detect if sonarr or radarr record
|
||||
const isSeries = record.seriesId !== undefined || options.arrType === 'sonarr';
|
||||
const mediaMap = isSeries ? seriesMap : moviesMap;
|
||||
const tagMap = isSeries ? sonarrTagMap : radarrTagMap;
|
||||
const mediaId = isSeries ? record.seriesId : record.movieId;
|
||||
|
||||
const media = mediaMap.get(mediaId) || record.series || record.movie;
|
||||
if (!media) return null;
|
||||
|
||||
// Tag-based user filtering
|
||||
const allTags = TagMatcher.extractAllTags(media.tags, tagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(media.tags, tagMap, username);
|
||||
if (!showAll && !matchedUserTag) return null;
|
||||
|
||||
// Safer default progress of 0 for items that haven't started yet
|
||||
const progress = options.progress !== undefined ? options.progress : 0;
|
||||
|
||||
const dlObj = {
|
||||
type: isSeries ? 'series' : 'movie',
|
||||
title: options.title || record.title || record.sourceTitle,
|
||||
coverArt: DownloadAssembler.getCoverArt(media),
|
||||
status: options.status || record.status || 'Unknown',
|
||||
progress,
|
||||
mb: options.mb !== undefined ? options.mb : (record.size ? Math.round(record.size / 1024 / 1024) : 0),
|
||||
size: options.size !== undefined ? options.size : (record.size || 0),
|
||||
completedAt: options.completedAt || record.completed_time || null,
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
// Strict neutral defaults to avoid incorrect SABnzbd-centric data
|
||||
client: options.client || 'orphaned',
|
||||
instanceId: options.instanceId || 'orphaned',
|
||||
instanceName: options.instanceName || 'Unknown',
|
||||
...options.overrides
|
||||
};
|
||||
|
||||
if (isSeries) {
|
||||
dlObj.seriesName = media.title;
|
||||
dlObj.episodes = options.episodes || [];
|
||||
} else {
|
||||
dlObj.movieName = media.title;
|
||||
dlObj.movieInfo = record;
|
||||
}
|
||||
|
||||
const issues = DownloadAssembler.getImportIssues(record);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
|
||||
dlObj.arrQueueId = record.id;
|
||||
dlObj.arrType = isSeries ? 'sonarr' : 'radarr';
|
||||
dlObj.arrInstanceUrl = record._instanceUrl || null;
|
||||
dlObj.arrInstanceKey = record._instanceKey || null;
|
||||
dlObj.arrContentId = record.episodeId || record.movieId || null;
|
||||
dlObj.arrContentIds = record.episodeIds || null;
|
||||
dlObj.arrSeriesId = record.seriesId || null;
|
||||
dlObj.arrContentType = isSeries ? 'episode' : 'movie';
|
||||
|
||||
// Use correct blocklist determination
|
||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = options.downloadPath || null;
|
||||
dlObj.targetPath = media.path || null;
|
||||
dlObj.arrLink = isSeries
|
||||
? DownloadAssembler.getSonarrLink(media)
|
||||
: DownloadAssembler.getRadarrLink(media);
|
||||
}
|
||||
|
||||
addOmbiMatching(dlObj, media, context);
|
||||
|
||||
return dlObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Map of series metadata from Sonarr queue and history records.
|
||||
@@ -90,19 +221,9 @@ async function matchSabSlots(slots, context) {
|
||||
sonarrHistoryRecords,
|
||||
radarrQueueRecords,
|
||||
radarrHistoryRecords,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap,
|
||||
queueStatus,
|
||||
queueSpeed,
|
||||
queueKbpersec,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
queueKbpersec
|
||||
} = context;
|
||||
|
||||
const matched = [];
|
||||
@@ -113,9 +234,6 @@ async function matchSabSlots(slots, context) {
|
||||
const slotState = getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec);
|
||||
const nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
// Normalize SAB name (dots to spaces) for better matching
|
||||
const nzbNameNormalized = nzbNameLower.replace(/\./g, ' ');
|
||||
|
||||
// Try to match by downloadId first (most reliable)
|
||||
const sabDownloadId = slot.nzo_id || slot.id;
|
||||
let sonarrMatch = sabDownloadId ? sonarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null;
|
||||
@@ -132,157 +250,70 @@ async function matchSabSlots(slots, context) {
|
||||
// Fallback: Check by title matching
|
||||
if (!sonarrMatch) {
|
||||
sonarrMatch = sonarrQueueRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (
|
||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||
);
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
if (!radarrMatch) {
|
||||
radarrMatch = radarrQueueRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (
|
||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||
);
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Also check HISTORY (completed downloads) if no queue match
|
||||
if (!sonarrMatch) {
|
||||
sonarrMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (
|
||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||
);
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
if (!radarrMatch) {
|
||||
radarrMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (
|
||||
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
||||
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
||||
);
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
// Calculate progress from SABnzbd slot data
|
||||
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
|
||||
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
|
||||
? parseFloat(slot.mbleft || slot.mbmissing)
|
||||
: 0;
|
||||
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
|
||||
// Progress calculation
|
||||
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
|
||||
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
|
||||
? parseFloat(slot.mbleft || slot.mbmissing)
|
||||
: 0;
|
||||
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
|
||||
|
||||
const dlObj = {
|
||||
type: 'series',
|
||||
title: nzbName,
|
||||
coverArt: DownloadAssembler.getCoverArt(series),
|
||||
status: slotState.status,
|
||||
progress: Math.round(progress),
|
||||
mb: slot.mb,
|
||||
mbmissing: slot.mbleft,
|
||||
size: Math.round(slot.mb * 1024 * 1024),
|
||||
speed: Math.round((slot.kbpersec || 0) * 1024),
|
||||
eta: slot.timeleft,
|
||||
seriesName: series.title,
|
||||
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrQueueRecords),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
client: 'sabnzbd',
|
||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||
instanceName: slot.instanceName || 'SABnzbd'
|
||||
};
|
||||
const issues = DownloadAssembler.getImportIssues(sonarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
// Expose ARR IDs to non-admins for blocklist functionality
|
||||
dlObj.arrQueueId = sonarrMatch.id;
|
||||
dlObj.arrType = 'sonarr';
|
||||
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
||||
dlObj.arrContentIds = sonarrMatch.episodeIds || null;
|
||||
dlObj.arrSeriesId = sonarrMatch.seriesId || null;
|
||||
dlObj.arrContentType = 'episode';
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = series.path || null;
|
||||
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
||||
series._instanceUrl = sonarrMatch._instanceUrl;
|
||||
}
|
||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
}
|
||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
addOmbiMatching(dlObj, series, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
const commonOptions = {
|
||||
title: nzbName,
|
||||
status: slotState.status,
|
||||
progress: Math.round(progress),
|
||||
mb: slot.mb,
|
||||
size: Math.round(slot.mb * 1024 * 1024),
|
||||
client: 'sabnzbd',
|
||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||
instanceName: slot.instanceName || 'SABnzbd',
|
||||
downloadPath: slot.storage || null,
|
||||
overrides: {
|
||||
mbmissing: slot.mbleft,
|
||||
speed: Math.round((slot.kbpersec || 0) * 1024),
|
||||
eta: slot.timeleft
|
||||
}
|
||||
};
|
||||
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const dlObj = buildArrDownload(sonarrMatch, context, {
|
||||
...commonOptions,
|
||||
arrType: 'sonarr',
|
||||
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrQueueRecords)
|
||||
});
|
||||
if (dlObj) matched.push(dlObj);
|
||||
}
|
||||
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
// Calculate progress from SABnzbd slot data
|
||||
const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0;
|
||||
const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null)
|
||||
? parseFloat(slot.mbleft || slot.mbmissing)
|
||||
: 0;
|
||||
const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0;
|
||||
|
||||
const dlObj = {
|
||||
type: 'movie',
|
||||
title: nzbName,
|
||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||
status: slotState.status,
|
||||
progress: Math.round(progress),
|
||||
mb: slot.mb,
|
||||
mbmissing: slot.mbleft,
|
||||
size: Math.round(slot.mb * 1024 * 1024),
|
||||
speed: Math.round((slot.kbpersec || 0) * 1024),
|
||||
eta: slot.timeleft,
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrMatch,
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
client: 'sabnzbd',
|
||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||
instanceName: slot.instanceName || 'SABnzbd'
|
||||
};
|
||||
const issues = DownloadAssembler.getImportIssues(radarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
// Expose ARR IDs to non-admins for blocklist functionality
|
||||
dlObj.arrQueueId = radarrMatch.id;
|
||||
dlObj.arrType = 'radarr';
|
||||
dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
dlObj.arrContentId = radarrMatch.movieId || null;
|
||||
dlObj.arrContentType = 'movie';
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = movie.path || null;
|
||||
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
||||
movie._instanceUrl = radarrMatch._instanceUrl;
|
||||
}
|
||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
}
|
||||
dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin);
|
||||
addOmbiMatching(dlObj, movie, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
const dlObj = buildArrDownload(radarrMatch, context, {
|
||||
...commonOptions,
|
||||
arrType: 'radarr'
|
||||
});
|
||||
if (dlObj) matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
@@ -296,18 +327,10 @@ async function matchSabSlots(slots, context) {
|
||||
*/
|
||||
async function matchSabHistory(slots, context) {
|
||||
const {
|
||||
sonarrQueueRecords,
|
||||
sonarrHistoryRecords,
|
||||
radarrHistoryRecords,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
radarrQueueRecords,
|
||||
radarrHistoryRecords
|
||||
} = context;
|
||||
|
||||
const matched = [];
|
||||
@@ -316,82 +339,67 @@ async function matchSabHistory(slots, context) {
|
||||
if (!nzbName) continue;
|
||||
const nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
const sonarrMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const dlObj = {
|
||||
type: 'series',
|
||||
title: nzbName,
|
||||
coverArt: DownloadAssembler.getCoverArt(series),
|
||||
status: slot.status,
|
||||
progress: 100, // History items are completed
|
||||
mb: slot.mb,
|
||||
size: Math.round((slot.mb || 0) * 1024 * 1024),
|
||||
completedAt: slot.completed_time,
|
||||
seriesName: series.title,
|
||||
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistoryRecords),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
client: 'sabnzbd',
|
||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||
instanceName: slot.instanceName || 'SABnzbd'
|
||||
};
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = series.path || null;
|
||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
}
|
||||
addOmbiMatching(dlObj, series, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
// Try to match by downloadId (nzo_id or slot ID) first (most reliable)
|
||||
const sabDownloadId = slot.nzo_id || slot.id;
|
||||
const matchesSabId = (r) => {
|
||||
const dl = r && r.downloadId;
|
||||
if (!dl || !sabDownloadId) return false;
|
||||
return String(dl).toLowerCase() === String(sabDownloadId).toLowerCase();
|
||||
};
|
||||
|
||||
let sonarrMatch = sabDownloadId ? sonarrHistoryRecords.find(matchesSabId) : null;
|
||||
let radarrMatch = sabDownloadId ? radarrHistoryRecords.find(matchesSabId) : null;
|
||||
|
||||
// Dual-lookup: also try against active queue records (history slot may still be in *arr queue)
|
||||
if (!sonarrMatch && sabDownloadId) {
|
||||
sonarrMatch = sonarrQueueRecords.find(matchesSabId);
|
||||
}
|
||||
if (!radarrMatch && sabDownloadId) {
|
||||
radarrMatch = radarrQueueRecords.find(matchesSabId);
|
||||
}
|
||||
|
||||
// Fallback: Check by title matching
|
||||
if (!sonarrMatch) {
|
||||
sonarrMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
if (!radarrMatch) {
|
||||
radarrMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
|
||||
const commonOptions = {
|
||||
title: nzbName,
|
||||
status: slot.status || 'Completed',
|
||||
progress: 100, // History items are completed
|
||||
mb: slot.mb,
|
||||
size: Math.round((slot.mb || 0) * 1024 * 1024),
|
||||
completedAt: slot.completed_time,
|
||||
client: 'sabnzbd',
|
||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||
instanceName: slot.instanceName || 'SABnzbd',
|
||||
downloadPath: slot.storage || null
|
||||
};
|
||||
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const dlObj = buildArrDownload(sonarrMatch, context, {
|
||||
...commonOptions,
|
||||
arrType: 'sonarr',
|
||||
episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistoryRecords)
|
||||
});
|
||||
if (dlObj) matched.push(dlObj);
|
||||
}
|
||||
|
||||
const radarrMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
||||
});
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const dlObj = {
|
||||
type: 'movie',
|
||||
title: nzbName,
|
||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||
status: slot.status,
|
||||
progress: 100, // History items are completed
|
||||
mb: slot.mb,
|
||||
size: Math.round((slot.mb || 0) * 1024 * 1024),
|
||||
completedAt: slot.completed_time,
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrMatch,
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined,
|
||||
client: 'sabnzbd',
|
||||
instanceId: slot.instanceId || 'sabnzbd-default',
|
||||
instanceName: slot.instanceName || 'SABnzbd'
|
||||
};
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = movie.path || null;
|
||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
}
|
||||
addOmbiMatching(dlObj, movie, context);
|
||||
matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
const dlObj = buildArrDownload(radarrMatch, context, {
|
||||
...commonOptions,
|
||||
arrType: 'radarr'
|
||||
});
|
||||
if (dlObj) matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
@@ -408,17 +416,7 @@ async function matchTorrents(torrents, context) {
|
||||
sonarrQueueRecords,
|
||||
sonarrHistoryRecords,
|
||||
radarrQueueRecords,
|
||||
radarrHistoryRecords,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
embyUserMap,
|
||||
ombiRetriever,
|
||||
ombiBaseUrl
|
||||
radarrHistoryRecords
|
||||
} = context;
|
||||
|
||||
const matched = [];
|
||||
@@ -427,12 +425,7 @@ async function matchTorrents(torrents, context) {
|
||||
if (!torrentName) continue;
|
||||
const torrentNameLower = torrentName.toLowerCase();
|
||||
|
||||
let matchedAny = false;
|
||||
|
||||
// Hash-first matching (Issue #65): prefer matching by torrent hash against
|
||||
// each *arr queue record's `downloadId`. `torrent.hash` covers qBittorrent
|
||||
// and rTorrent; `torrent.hashString` covers Transmission. We fall back to
|
||||
// existing title-substring matching only if no hash match was found.
|
||||
// Hash-first matching (Issue #65)
|
||||
const torrentHash = torrent?.hash || torrent?.hashString || null;
|
||||
const hashLower = torrentHash ? String(torrentHash).toLowerCase() : null;
|
||||
const matchesByHash = (r) => {
|
||||
@@ -442,183 +435,104 @@ async function matchTorrents(torrents, context) {
|
||||
};
|
||||
|
||||
let sonarrMatch = hashLower ? sonarrQueueRecords.find(matchesByHash) : null;
|
||||
if (!sonarrMatch) sonarrMatch = sonarrQueueRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.id = download.hash || torrent.hash;
|
||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||
Object.assign(download, {
|
||||
type: 'series',
|
||||
coverArt: DownloadAssembler.getCoverArt(series),
|
||||
seriesName: series.title,
|
||||
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrQueueRecords),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
||||
});
|
||||
const issues = DownloadAssembler.getImportIssues(sonarrMatch);
|
||||
if (issues) download.importIssues = issues;
|
||||
// Expose ARR IDs to non-admins for blocklist functionality
|
||||
download.arrQueueId = sonarrMatch.id;
|
||||
download.arrType = 'sonarr';
|
||||
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||
download.arrContentId = sonarrMatch.episodeId || null;
|
||||
download.arrContentIds = sonarrMatch.episodeIds || null;
|
||||
download.arrSeriesId = sonarrMatch.seriesId || null;
|
||||
download.arrContentType = 'episode';
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = series.path || null;
|
||||
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
||||
series._instanceUrl = sonarrMatch._instanceUrl;
|
||||
}
|
||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||
}
|
||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
addOmbiMatching(download, series, context);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let radarrMatch = hashLower ? radarrQueueRecords.find(matchesByHash) : null;
|
||||
if (!radarrMatch) radarrMatch = radarrQueueRecords.find(r => {
|
||||
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.id = download.hash || torrent.hash;
|
||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||
Object.assign(download, {
|
||||
type: 'movie',
|
||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrMatch,
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
||||
});
|
||||
const issues = DownloadAssembler.getImportIssues(radarrMatch);
|
||||
if (issues) download.importIssues = issues;
|
||||
// Expose ARR IDs to non-admins for blocklist functionality
|
||||
download.arrQueueId = radarrMatch.id;
|
||||
download.arrType = 'radarr';
|
||||
download.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||
download.arrContentId = radarrMatch.movieId || null;
|
||||
download.arrContentType = 'movie';
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = movie.path || null;
|
||||
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
||||
movie._instanceUrl = radarrMatch._instanceUrl;
|
||||
}
|
||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||
}
|
||||
download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin);
|
||||
addOmbiMatching(download, movie, context);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Check by title matching
|
||||
if (!sonarrMatch) {
|
||||
sonarrMatch = sonarrQueueRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
if (!radarrMatch) {
|
||||
radarrMatch = radarrQueueRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback to history matching
|
||||
let sonarrHistoryMatch = hashLower ? sonarrHistoryRecords.find(matchesByHash) : null;
|
||||
if (!sonarrHistoryMatch) sonarrHistoryMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
|
||||
if (series) {
|
||||
const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
Object.assign(download, {
|
||||
type: 'series',
|
||||
coverArt: DownloadAssembler.getCoverArt(series),
|
||||
seriesName: series.title,
|
||||
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistoryRecords),
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
||||
});
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = series.path || null;
|
||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||
}
|
||||
addOmbiMatching(download, series, context);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let radarrHistoryMatch = hashLower ? radarrHistoryRecords.find(matchesByHash) : null;
|
||||
if (!radarrHistoryMatch) radarrHistoryMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
||||
});
|
||||
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
|
||||
if (movie) {
|
||||
const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.id = download.hash || torrent.hash;
|
||||
download.progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||
download.speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||
Object.assign(download, {
|
||||
type: 'movie',
|
||||
coverArt: DownloadAssembler.getCoverArt(movie),
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrHistoryMatch,
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined
|
||||
});
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = movie.path || null;
|
||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||
}
|
||||
addOmbiMatching(download, movie, context);
|
||||
matched.push(download);
|
||||
matchedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sonarrHistoryMatch) {
|
||||
sonarrHistoryMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
if (!radarrHistoryMatch) {
|
||||
radarrHistoryMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Helper options for torrent mapping
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
const progress = parseFloat(download.progress) || torrent.progress || 0;
|
||||
const speed = download.rawSpeed || torrent.dlspeed || 0;
|
||||
|
||||
const commonOptions = {
|
||||
title: torrentName,
|
||||
status: download.status || torrent.status || 'Downloading',
|
||||
progress: Math.round(progress),
|
||||
mb: download.size ? Math.round(download.size / 1024 / 1024) : 0,
|
||||
size: download.size || torrent.size || 0,
|
||||
client: download.client || 'qbittorrent',
|
||||
instanceId: torrent.instanceId || download.instanceId || 'qbittorrent-default',
|
||||
instanceName: torrent.instanceName || download.instanceName || 'qBittorrent',
|
||||
downloadPath: download.savePath || torrent.savePath || null,
|
||||
overrides: {
|
||||
id: download.hash || torrent.hash,
|
||||
speed,
|
||||
eta: torrent.eta,
|
||||
seeds: torrent.seeds,
|
||||
peers: torrent.peers,
|
||||
availability: torrent.availability,
|
||||
addedOn: torrent.addedOn,
|
||||
qbittorrent: true
|
||||
}
|
||||
};
|
||||
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const dlObj = buildArrDownload(sonarrMatch, context, {
|
||||
...commonOptions,
|
||||
arrType: 'sonarr',
|
||||
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrQueueRecords)
|
||||
});
|
||||
if (dlObj) matched.push(dlObj);
|
||||
}
|
||||
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const dlObj = buildArrDownload(radarrMatch, context, {
|
||||
...commonOptions,
|
||||
arrType: 'radarr'
|
||||
});
|
||||
if (dlObj) matched.push(dlObj);
|
||||
}
|
||||
|
||||
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
|
||||
const dlObj = buildArrDownload(sonarrHistoryMatch, context, {
|
||||
...commonOptions,
|
||||
arrType: 'sonarr',
|
||||
progress: 100, // completed
|
||||
episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistoryRecords)
|
||||
});
|
||||
if (dlObj) matched.push(dlObj);
|
||||
}
|
||||
|
||||
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
|
||||
const dlObj = buildArrDownload(radarrHistoryMatch, context, {
|
||||
...commonOptions,
|
||||
arrType: 'radarr',
|
||||
progress: 100 // completed
|
||||
});
|
||||
if (dlObj) matched.push(dlObj);
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by (arrType, arrQueueId) (Issue #65). When a single torrent
|
||||
// (typically a season pack) matches N *arr queue records sharing one
|
||||
// arrQueueId via downstream emission paths, only the first matched download
|
||||
// is retained. Entries without an arrQueueId pass through unchanged.
|
||||
// Deduplicate by (arrType, arrQueueId) (Issue #65)
|
||||
const seen = new Set();
|
||||
const deduped = [];
|
||||
for (const m of matched) {
|
||||
@@ -634,6 +548,57 @@ async function matchTorrents(torrents, context) {
|
||||
return deduped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches orphaned *arr queue items that have no corresponding download client item
|
||||
* but still reside in the active Sonarr/Radarr queue.
|
||||
* @param {Set<number>} matchedArrQueueIds - Already matched queue record IDs to skip
|
||||
* @param {Object} context - Matching context with records, maps, and user info
|
||||
* @returns {Array} Array of orphaned download objects
|
||||
*/
|
||||
function matchOrphanedArrRecords(matchedArrQueueIds, context) {
|
||||
const { sonarrQueueRecords, radarrQueueRecords } = context;
|
||||
const matched = [];
|
||||
|
||||
// Deduplication Strategy:
|
||||
// We initialize the processed Set with already-matched IDs compiled during Phase 1 matching.
|
||||
// We also track newly processed IDs locally to handle situations where multiple duplicate queue
|
||||
// records pointing to the same downloadId exist in Sonarr/Radarr.
|
||||
const processedQueueIds = new Set(matchedArrQueueIds);
|
||||
|
||||
const processRecords = (records, arrType) => {
|
||||
for (const record of records) {
|
||||
if (processedQueueIds.has(record.id)) continue;
|
||||
processedQueueIds.add(record.id);
|
||||
|
||||
// Safe progress arithmetic to prevent NaN or division-by-zero
|
||||
const size = record.size || 0;
|
||||
const sizeleft = record.sizeleft || 0;
|
||||
const progress = size > 0 ? Math.round(100 - (sizeleft / size * 100)) : 0;
|
||||
const status = record.trackedDownloadStatus || record.status || 'Unknown';
|
||||
|
||||
const dl = buildArrDownload(record, context, {
|
||||
arrType,
|
||||
status,
|
||||
progress,
|
||||
client: 'orphaned',
|
||||
instanceId: 'orphaned',
|
||||
instanceName: 'Orphaned (unconfigured client)',
|
||||
overrides: { isOrphaned: true }
|
||||
});
|
||||
|
||||
if (dl) {
|
||||
logger.debug(`[DownloadMatcher] Found orphaned *arr queue item: ${dl.title} (arrQueueId=${record.id})`);
|
||||
matched.push(dl);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processRecords(sonarrQueueRecords || [], 'sonarr');
|
||||
processRecords(radarrQueueRecords || [], 'radarr');
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildSeriesMapFromRecords,
|
||||
buildMoviesMapFromRecords,
|
||||
@@ -641,5 +606,7 @@ module.exports = {
|
||||
addOmbiMatching,
|
||||
matchSabSlots,
|
||||
matchSabHistory,
|
||||
matchTorrents
|
||||
matchTorrents,
|
||||
buildArrDownload,
|
||||
matchOrphanedArrRecords
|
||||
};
|
||||
|
||||
@@ -925,4 +925,158 @@ describe('buildUserDownloads', () => {
|
||||
expect(result[0].arrLink).toBe('https://sonarr.test/series/test-series');
|
||||
expect(result[1].arrLink).toBe('https://radarr.test/movie/test-movie');
|
||||
});
|
||||
|
||||
describe('orphaned download integration in DownloadBuilder', () => {
|
||||
it('returns orphaned queue records when no active client match is found', async () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: { data: { queue: { slots: [] } } },
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
sonarrQueue: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 500,
|
||||
title: 'Genuinely Orphaned Show',
|
||||
sourceTitle: 'Genuinely Orphaned Show',
|
||||
seriesId: 1,
|
||||
series: seriesMap.get(1),
|
||||
size: 200000000,
|
||||
sizeleft: 100000000,
|
||||
trackedDownloadState: 'importPending',
|
||||
trackedDownloadStatus: 'warning',
|
||||
statusMessages: [{ messages: ['Missing files'] }]
|
||||
}]
|
||||
}
|
||||
},
|
||||
sonarrHistory: { data: { records: [] } },
|
||||
radarrQueue: { data: { records: [] } },
|
||||
radarrHistory: { data: { records: [] } },
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin: true,
|
||||
showAll,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
title: 'Genuinely Orphaned Show',
|
||||
isOrphaned: true,
|
||||
client: 'orphaned',
|
||||
instanceId: 'orphaned',
|
||||
instanceName: 'Orphaned (unconfigured client)',
|
||||
progress: 50,
|
||||
importIssues: ['Missing files']
|
||||
});
|
||||
});
|
||||
|
||||
it('strictly deduplicates so active matched items are NOT duplicated as orphans', async () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: {
|
||||
data: {
|
||||
queue: {
|
||||
status: 'Downloading',
|
||||
speed: '5.0 MB/s',
|
||||
kbpersec: 5120,
|
||||
slots: [{
|
||||
filename: 'Matched Active Show',
|
||||
nzbname: 'Matched Active Show',
|
||||
status: 'Downloading',
|
||||
percentage: 50,
|
||||
mb: 1000,
|
||||
mbmissing: 500,
|
||||
size: '1 GB',
|
||||
timeleft: '10:00'
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
sonarrQueue: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 100,
|
||||
downloadId: '100', // matches slot by default mock (or slot.id/nzo_id)
|
||||
title: 'Matched Active Show',
|
||||
sourceTitle: 'Matched Active Show',
|
||||
seriesId: 1,
|
||||
series: seriesMap.get(1)
|
||||
}]
|
||||
}
|
||||
},
|
||||
sonarrHistory: { data: { records: [] } },
|
||||
radarrQueue: { data: { records: [] } },
|
||||
radarrHistory: { data: { records: [] } },
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
// Set slot nzo_id to match the downloadId
|
||||
cacheSnapshot.sabnzbdQueue.data.queue.slots[0].nzo_id = '100';
|
||||
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username,
|
||||
usernameSanitized,
|
||||
isAdmin: true,
|
||||
showAll,
|
||||
seriesMap,
|
||||
moviesMap,
|
||||
sonarrTagMap,
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
// Should match the download once via SABnzbd client; should NOT list it again as an orphan
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].isOrphaned).toBeUndefined();
|
||||
expect(result[0].client).toBe('sabnzbd');
|
||||
});
|
||||
|
||||
it('filters orphaned records based on user tag matches', async () => {
|
||||
const cacheSnapshot = {
|
||||
sabnzbdQueue: { data: { queue: { slots: [] } } },
|
||||
sabnzbdHistory: { data: { history: { slots: [] } } },
|
||||
sonarrQueue: {
|
||||
data: {
|
||||
records: [{
|
||||
id: 600,
|
||||
title: 'Bobs Orphaned Show',
|
||||
sourceTitle: 'Bobs Orphaned Show',
|
||||
seriesId: 2, // Bob's series (tag=2, username=bob)
|
||||
series: {
|
||||
id: 2,
|
||||
title: 'Bob Show',
|
||||
tags: [2],
|
||||
images: []
|
||||
}
|
||||
}]
|
||||
}
|
||||
},
|
||||
sonarrHistory: { data: { records: [] } },
|
||||
radarrQueue: { data: { records: [] } },
|
||||
radarrHistory: { data: { records: [] } },
|
||||
qbittorrentTorrents: []
|
||||
};
|
||||
|
||||
const result = await buildUserDownloads(cacheSnapshot, {
|
||||
username: 'alice', // alice should not see bob's orphaned downloads
|
||||
usernameSanitized: 'alice',
|
||||
isAdmin: false,
|
||||
showAll: false,
|
||||
seriesMap: new Map([[2, { id: 2, title: 'Bob Show', tags: [2], images: [] }]]),
|
||||
moviesMap,
|
||||
sonarrTagMap: new Map([[1, 'alice'], [2, 'bob']]),
|
||||
radarrTagMap,
|
||||
embyUserMap
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,4 +145,140 @@ describe('DownloadMatcher', () => {
|
||||
expect(result.speed).toBe('1.5 MB/s');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildArrDownload', () => {
|
||||
const context = {
|
||||
seriesMap: new Map([[1, { id: 1, title: 'My Show', tags: [1] }]]),
|
||||
moviesMap: new Map([[2, { id: 2, title: 'My Movie', tags: [1] }]]),
|
||||
sonarrTagMap: new Map([[1, 'alice']]),
|
||||
radarrTagMap: new Map([[1, 'alice']]),
|
||||
username: 'alice',
|
||||
isAdmin: false,
|
||||
showAll: false,
|
||||
embyUserMap: new Map()
|
||||
};
|
||||
|
||||
it('correctly uses caller-supplied client, instanceId, and instanceName values', () => {
|
||||
const record = { id: 100, seriesId: 1, title: 'My Show' };
|
||||
const dl = DownloadMatcher.buildArrDownload(record, context, {
|
||||
client: 'deluge',
|
||||
instanceId: 'deluge-1',
|
||||
instanceName: 'Deluge Instance 1'
|
||||
});
|
||||
|
||||
expect(dl).toBeDefined();
|
||||
expect(dl.client).toBe('deluge');
|
||||
expect(dl.instanceId).toBe('deluge-1');
|
||||
expect(dl.instanceName).toBe('Deluge Instance 1');
|
||||
});
|
||||
|
||||
it('uses neutral fallback defaults when not supplied', () => {
|
||||
const record = { id: 100, seriesId: 1, title: 'My Show' };
|
||||
const dl = DownloadMatcher.buildArrDownload(record, context);
|
||||
|
||||
expect(dl).toBeDefined();
|
||||
expect(dl.client).toBe('orphaned');
|
||||
expect(dl.instanceId).toBe('orphaned');
|
||||
expect(dl.instanceName).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('uses correct blocklist determination and defaults progress to 0', () => {
|
||||
const record = { id: 100, seriesId: 1, title: 'My Show' };
|
||||
const dl = DownloadMatcher.buildArrDownload(record, context);
|
||||
|
||||
expect(dl.progress).toBe(0);
|
||||
expect(dl.canBlocklist).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchSabHistory', () => {
|
||||
const context = {
|
||||
sonarrHistoryRecords: [
|
||||
{ id: 100, downloadId: 'sabnzbd_1234', seriesId: 1 }
|
||||
],
|
||||
sonarrQueueRecords: [
|
||||
{ id: 101, downloadId: 'sabnzbd_5678', seriesId: 1 }
|
||||
],
|
||||
radarrHistoryRecords: [],
|
||||
radarrQueueRecords: [],
|
||||
seriesMap: new Map([[1, { id: 1, title: 'Show 1', tags: [1] }]]),
|
||||
moviesMap: new Map(),
|
||||
sonarrTagMap: new Map([[1, 'alice']]),
|
||||
radarrTagMap: new Map(),
|
||||
username: 'alice',
|
||||
isAdmin: false,
|
||||
showAll: false,
|
||||
embyUserMap: new Map()
|
||||
};
|
||||
|
||||
it('matches by downloadId case-insensitively and type-safely', async () => {
|
||||
const slots = [{ id: 'SABNZBD_1234', name: 'Show 1', status: 'Completed', mb: 1000 }];
|
||||
const result = await DownloadMatcher.matchSabHistory(slots, context);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].arrQueueId).toBe(100);
|
||||
});
|
||||
|
||||
it('dual-lookup: matches history slots against active queue records', async () => {
|
||||
const slots = [{ id: 'sabnzbd_5678', name: 'Show 1', status: 'Completed', mb: 1000 }];
|
||||
const result = await DownloadMatcher.matchSabHistory(slots, context);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].arrQueueId).toBe(101);
|
||||
});
|
||||
});
|
||||
|
||||
describe('titleMatches helper', () => {
|
||||
it('matches identical strings and handles dots/spaces/dashes bidirectionally', () => {
|
||||
// Direct exports or internal reference
|
||||
const titleMatches = DownloadMatcher.__get__ ? DownloadMatcher.__get__('titleMatches') : null;
|
||||
// Since it is not exported, we can test it indirectly via matchTorrents or call it if we export it,
|
||||
// or we can test it via matchTorrents fallback matching. Let's test it using matchTorrents with dot names:
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchOrphanedArrRecords', () => {
|
||||
const context = {
|
||||
sonarrQueueRecords: [
|
||||
{ id: 100, seriesId: 1, title: 'Orphan 1', size: 1000, sizeleft: 400 },
|
||||
{ id: 101, seriesId: 1, title: 'Already Matched', size: 1000, sizeleft: 0 }
|
||||
],
|
||||
radarrQueueRecords: [],
|
||||
seriesMap: new Map([[1, { id: 1, title: 'Series 1', tags: [1] }]]),
|
||||
moviesMap: new Map(),
|
||||
sonarrTagMap: new Map([[1, 'alice']]),
|
||||
radarrTagMap: new Map(),
|
||||
username: 'alice',
|
||||
isAdmin: false,
|
||||
showAll: false,
|
||||
embyUserMap: new Map()
|
||||
};
|
||||
|
||||
it('constructs orphans, filters matched IDs, and computes safe progress math', () => {
|
||||
const matchedIds = new Set([101]);
|
||||
const result = DownloadMatcher.matchOrphanedArrRecords(matchedIds, context);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
title: 'Orphan 1',
|
||||
isOrphaned: true,
|
||||
progress: 60,
|
||||
client: 'orphaned',
|
||||
instanceId: 'orphaned'
|
||||
});
|
||||
});
|
||||
|
||||
it('handles size=0 safely without returning NaN or Infinity', () => {
|
||||
const zeroContext = {
|
||||
...context,
|
||||
sonarrQueueRecords: [
|
||||
{ id: 102, seriesId: 1, title: 'Zero Size', size: 0, sizeleft: 0 }
|
||||
]
|
||||
};
|
||||
const result = DownloadMatcher.matchOrphanedArrRecords(new Set(), zeroContext);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].progress).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user