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:
@@ -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