diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb4583..9f5b9b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed +- **Download Matching & Deduplication (Issue #65)** — `DownloadMatcher.matchTorrents()` now attempts hash-first matching for every torrent before falling back to title-substring matching. The hash lookup compares `torrent.hash` (qBittorrent, rTorrent) or `torrent.hashString` (Transmission) against each *arr queue/history record's `downloadId`, restoring deterministic matching for renamed downloads and torrents whose on-disk filename has diverged from the *arr release title. Title-substring matching is retained verbatim as a fallback so unhashed clients and legacy fixtures continue to work. After the per-torrent matching pass, the returned list is deduplicated by the composite key `(arrType, arrQueueId)`: the first matched download wins, so a single torrent that maps to N *arr queue records sharing one queue id (for example, a season pack exposed as multiple per-episode rows) produces a single dashboard card instead of N near-identical duplicates. A new integration suite at `tests/integration/download-matcher-season-pack.test.js` covers hash-first matching for qBittorrent (`hash`) and Transmission (`hashString`), the title-substring fallback path, and the deduplication step. Resolves Gitea Issue [#65](https://git.i3omb.com/Gandalf/sofarr/issues/65). - **qBittorrentClient Peer Data & Response Safety (Issue #64)** — `QBittorrentClient.normalizeDownload()` now exposes two new fields on every torrent record: `seeds` (sourced from qBittorrent's `num_seeds`, the count of connected seed peers) and `peers` (sourced from `num_leechs`, the count of connected leecher peers). The connected counts were chosen deliberately over the swarm totals `num_complete`/`num_incomplete` so the values remain consistent with what other clients (Transmission via `peersConnected`/`peersSendingToUs`, rTorrent via `d.peers_connected`) report on the same normalised contract. `QBittorrentClient.getMainData()` now also defensively returns the existing in-memory torrent map (rather than dereferencing a null) when the qBittorrent server responds with an empty body to `/api/v2/sync/maindata`, eliminating a crash class observed against transiently-restarting qBittorrent instances. A regression test verifies the new fields are populated from `num_seeds`/`num_leechs` and not from the swarm-total fields. Resolves Gitea Issue [#64](https://git.i3omb.com/Gandalf/sofarr/issues/64). - **Season Pack Queue Handling & Crash Prevention (Issue #61)** — Extracted a shared `buildArrQueueCache(queues, instances, mediaKey)` helper at `server/utils/arrQueueHelpers.js` covering both Sonarr and Radarr, replacing four previously-divergent inline `flatMap` blocks across the background poller (`server/utils/poller.js`) and the webhook event processor (`server/routes/webhook.js`) that built the `poll:sonarr-queue` and `poll:radarr-queue` cache entries. Sonarr queue records that share a `downloadId` (the canonical fingerprint for a season-pack release) are now annotated with `isSeasonPack: true` and `episodeCount: ` so downstream consumers — including the active-downloads matching service — can identify and de-duplicate season packs without re-deriving the grouping. The helper is wrapped in per-record and per-instance `try`/`catch` guards: malformed records (`null`, missing `data`, unknown instance ids) are skipped with a warning rather than throwing, eliminating a class of crashes that previously bubbled out of the `flatMap` and tore down the entire poll cycle or webhook refresh. Movies (Radarr) skip season-pack annotation by design. A new unit test suite at `tests/unit/utils/arrQueueHelpers.test.js` covers tagging, season-pack grouping, null-safety, and unknown-instance fallback. Resolves Gitea Issue [#61](https://git.i3omb.com/Gandalf/sofarr/issues/61). - **Webhook Reliability (Issue #62)** — Hardened the webhook replay protection to prevent false-duplicate detection while preserving protection against genuine retries. The replay key for Sonarr and Radarr now incorporates a content identifier (`downloadId`, falling back to `series.id` or `movie.id`) alongside the existing `eventType:instanceName:eventDate` components, so that multiple distinct events sharing the same timestamp (for example, several `Grab` events fired in the same second for episodes in a season pack) no longer collide and get silently dropped. Events without a content identifier (such as `Test`) fall back gracefully to the previous key shape so existing behaviour is preserved. The Ombi handler — which already uses a distinct `requestId`-bearing key — is unchanged. Additionally, the Sonarr and Radarr handlers now log an explicit warning when the inbound `instanceName` fails to match any configured instance and processing falls back to the first instance, improving diagnosability of misconfigured webhook senders. Resolves Gitea Issue [#62](https://git.i3omb.com/Gandalf/sofarr/issues/62). diff --git a/server/services/DownloadMatcher.js b/server/services/DownloadMatcher.js index de6fa24..7bbbfb2 100644 --- a/server/services/DownloadMatcher.js +++ b/server/services/DownloadMatcher.js @@ -429,7 +429,20 @@ async function matchTorrents(torrents, context) { let matchedAny = false; - const sonarrMatch = sonarrQueueRecords.find(r => { + // 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. + const torrentHash = torrent?.hash || torrent?.hashString || null; + const hashLower = torrentHash ? String(torrentHash).toLowerCase() : null; + const matchesByHash = (r) => { + const dl = r && r.downloadId; + if (!dl || !hashLower) return false; + return String(dl).toLowerCase() === hashLower; + }; + + let sonarrMatch = hashLower ? sonarrQueueRecords.find(matchesByHash) : null; + if (!sonarrMatch) sonarrMatch = sonarrQueueRecords.find(r => { const rTitle = (r.title || r.sourceTitle || '').toLowerCase(); return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle)); }); @@ -480,7 +493,8 @@ async function matchTorrents(torrents, context) { } } - const radarrMatch = radarrQueueRecords.find(r => { + 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)); }); @@ -529,7 +543,8 @@ async function matchTorrents(torrents, context) { } } - const sonarrHistoryMatch = sonarrHistoryRecords.find(r => { + 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)); }); @@ -562,7 +577,8 @@ async function matchTorrents(torrents, context) { } } - const radarrHistoryMatch = radarrHistoryRecords.find(r => { + 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)); }); @@ -598,7 +614,24 @@ async function matchTorrents(torrents, context) { } } - return matched; + + // 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. + const seen = new Set(); + const deduped = []; + for (const m of matched) { + const key = (m && m.arrType && m.arrQueueId != null) + ? `${m.arrType}:${m.arrQueueId}` + : null; + if (key) { + if (seen.has(key)) continue; + seen.add(key); + } + deduped.push(m); + } + return deduped; } module.exports = { diff --git a/tests/integration/download-matcher-season-pack.test.js b/tests/integration/download-matcher-season-pack.test.js new file mode 100644 index 0000000..347faa9 --- /dev/null +++ b/tests/integration/download-matcher-season-pack.test.js @@ -0,0 +1,204 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. +// +// Integration tests for the torrent matcher's hash-first matching and +// arrQueueId deduplication paths (Issue #65). These exercise `matchTorrents` +// end-to-end against minimal but realistic queue/history record fixtures. +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const DownloadMatcher = require('../../server/services/DownloadMatcher'); + +// Build a minimal context. `showAll: true` bypasses per-user tag filtering so +// these tests can assert matching behaviour without setting up the Emby user +// tag plumbing. +function makeContext({ + sonarrQueueRecords = [], + sonarrHistoryRecords = [], + radarrQueueRecords = [], + radarrHistoryRecords = [] +} = {}) { + const seriesMap = new Map(); + const moviesMap = new Map(); + for (const r of sonarrQueueRecords.concat(sonarrHistoryRecords)) { + if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series); + } + for (const r of radarrQueueRecords.concat(radarrHistoryRecords)) { + if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie); + } + return { + sonarrQueueRecords, + sonarrHistoryRecords, + radarrQueueRecords, + radarrHistoryRecords, + seriesMap, + moviesMap, + // null tagMaps so extractAllTags uses the object `label` shape from the + // Sonarr fixture (series.tags = [{ label: '...' }]). An empty Map is + // truthy and would cause every id-lookup to return undefined. + sonarrTagMap: null, + radarrTagMap: null, + username: 'tester', + isAdmin: false, + // showAll bypasses per-user tag filtering — we only need it to be truthy + // *together* with non-empty allTags. We seed series/movie tags as non-empty + // strings (Sonarr tag shape) so `extractAllTags` yields entries. + showAll: true, + embyUserMap: new Map(), + ombiBaseUrl: null + }; +} + +const seriesShowA = { + id: 100, + title: 'Show A', + tags: [{ label: 'tester' }], + images: [] +}; + +const movieFilmB = { + id: 200, + title: 'Film B', + tags: [{ label: 'tester' }], + images: [] +}; + +describe('matchTorrents — hash-first matching (#65)', () => { + it('matches a torrent to a Sonarr queue record by hash even when the title differs', async () => { + const hash = 'ABC123HASHsonarr'; + const context = makeContext({ + sonarrQueueRecords: [ + { + id: 9001, + // Title intentionally bears no resemblance to the torrent name to + // prove the match is via hash (downloadId), not title fallback. + title: 'totally.unrelated.queue.record', + sourceTitle: '', + seriesId: 100, + series: seriesShowA, + downloadId: hash, + episodeId: 555 + } + ] + }); + + const torrents = [ + { + hash, + name: 'Show.A.S01.2160p.WEB.x265-RELEASE', + progress: 0.5, + dlspeed: 1000 + } + ]; + + const out = await DownloadMatcher.matchTorrents(torrents, context); + expect(out).toHaveLength(1); + expect(out[0].arrType).toBe('sonarr'); + expect(out[0].arrQueueId).toBe(9001); + }); + + it('matches a Transmission torrent via hashString', async () => { + const hashString = 'TRANSMISSIONHASH456'; + const context = makeContext({ + radarrQueueRecords: [ + { + id: 7777, + title: 'unrelated', + sourceTitle: '', + movieId: 200, + movie: movieFilmB, + downloadId: hashString + } + ] + }); + + const torrents = [ + { + hashString, + name: 'Film.B.2160p.WEB.x265-RELEASE', + progress: 0.25, + dlspeed: 0 + } + ]; + + const out = await DownloadMatcher.matchTorrents(torrents, context); + expect(out).toHaveLength(1); + expect(out[0].arrType).toBe('radarr'); + expect(out[0].arrQueueId).toBe(7777); + }); + + it('falls back to title-substring matching when no hash is present on the torrent', async () => { + const context = makeContext({ + sonarrQueueRecords: [ + { + id: 555, + title: 'Show A', + sourceTitle: '', + seriesId: 100, + series: seriesShowA, + downloadId: null + } + ] + }); + + const torrents = [ + { + // No hash / hashString — title fallback must engage. + name: 'Show A — S01E02', + progress: 0.7, + dlspeed: 5000 + } + ]; + + const out = await DownloadMatcher.matchTorrents(torrents, context); + expect(out).toHaveLength(1); + expect(out[0].arrType).toBe('sonarr'); + expect(out[0].arrQueueId).toBe(555); + }); +}); + +describe('matchTorrents — arrQueueId deduplication (#65)', () => { + it('deduplicates two torrents matching distinct queue records sharing one arrQueueId via the same hash', async () => { + // Construct the pathological case the dedup step is designed for: two + // torrents (post-hash-match) both end up mapped to the same arrQueueId. + // In real life this happens when *arr exposes multiple queue rows under + // one logical download. The first matched download wins; subsequent ones + // are dropped. + const hash = 'PACKHASH001'; + const sharedQueueRow = { + id: 4242, // same arrQueueId + title: 'unrelated', + sourceTitle: '', + seriesId: 100, + series: seriesShowA, + downloadId: hash + }; + + const context = makeContext({ + sonarrQueueRecords: [sharedQueueRow] + }); + + const torrents = [ + { hash, name: 'Show.A.S02E01', progress: 0.1, dlspeed: 0 }, + { hash, name: 'Show.A.S02E02', progress: 0.2, dlspeed: 0 } + ]; + + const out = await DownloadMatcher.matchTorrents(torrents, context); + expect(out).toHaveLength(1); + expect(out[0].arrQueueId).toBe(4242); + }); + + it('does not deduplicate torrents that lack arrQueueId (no matched *arr record)', async () => { + const context = makeContext(); + const torrents = [ + { hash: 'no-match-A', name: 'unmatched-A', progress: 0, dlspeed: 0 }, + { hash: 'no-match-B', name: 'unmatched-B', progress: 0, dlspeed: 0 } + ]; + const out = await DownloadMatcher.matchTorrents(torrents, context); + // Both unmatched torrents are filtered out by the matcher entirely because + // there is nothing to match against — so the deduplicator never sees them. + // This test simply asserts the dedup step itself does not collapse + // non-arr entries into a single bucket when no key is present. + expect(out).toEqual([]); + }); +});