Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b40307a421 | |||
| 6c4aedf60e | |||
| 97e2f256e6 | |||
| 53eb19ba0c | |||
| 2f32edf77f | |||
| 0364a3c824 | |||
| d29b6e9223 | |||
| 7f7a91f056 | |||
| 879aee8eea | |||
| f8f693e32a | |||
| c18f5bd26e | |||
| e726fbe33f | |||
| 4107bdf611 | |||
| 52806d00dc | |||
| dcb77dd27f | |||
| 6c3ffb9b77 | |||
| 7226404221 | |||
| 0eaa54cf4a | |||
| fd0dc7528d | |||
| c4e584cc3b | |||
| 610632c4f0 | |||
| 1535a5725a | |||
| 8fb00843ef | |||
| 6f6aa5b967 | |||
| fb68bddedb | |||
| 7d7304637c |
+19
-1
@@ -2,7 +2,25 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).## [1.7.36] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Test Timeout & Cross-Suite Background Event Pollution (V8 Coverage)** — Configured `fileParallelism: false` and `testTimeout: 15000` in `vitest.config.js`. This guarantees that slow code compilation/instrumentation under V8 coverage doesn't cause transient 5-second timeouts, and prevents asynchronous fire-and-forget background event loops (like Ombi webhook retry loops) in one test suite from running concurrently and overwriting cache singletons in other test suites.
|
||||
|
||||
## [1.7.35] - 2026-05-29
|
||||
|
||||
### Added
|
||||
|
||||
- **Orphaned *arr Queue Item Support (Issue #73)** — Added support for active Sonarr/Radarr queue items from unconfigured download clients ("orphaned" downloads). Added a new synthetic client (`'orphaned'`) with a custom viewBox vector graphics asset at `/images/clients/orphaned.svg` to represent unconfigured clients, and updated filter dropdown lists and active downloads grids to cleanly display them with a dimmed logo, custom dashed border styling, and informative hover tooltips. Resolves Gitea Issue [#73](https://git.i3omb.com/Gandalf/sofarr/issues/73).
|
||||
|
||||
### Enhanced
|
||||
|
||||
- **Download Matching & JSDoc Hygiene (Issue #73)** — Refactored core active-download matching algorithms into unified, deduplicated helper functions (`normalizeTitle`, `titleMatches`, `buildArrDownload`) in `DownloadMatcher.js`, preventing hundreds of lines of duplicate code. Handled case-insensitive and type-safe `downloadId` lookup in `matchSabHistory` across both history and active queue records. Added safe progress arithmetic bounds checking to prevent division-by-zero or `NaN`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Security Metadata Isolation in buildArrDownload (Issue #73)** — Restricted access control for sensitive properties like `arrInstanceKey` (the raw instance API key) to ensure they are strictly stripped out of download objects for non-administrator users, preserving system security boundaries.
|
||||
|
||||
## [1.7.34] - 2026-05-28
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.34",
|
||||
"version": "1.7.36",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sofarr",
|
||||
"version": "1.7.34",
|
||||
"version": "1.7.36",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.34",
|
||||
"version": "1.7.36",
|
||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
* version:
|
||||
* type: string
|
||||
* description: sofarr version
|
||||
* example: "1.7.34"
|
||||
* example: "1.7.36"
|
||||
* x-code-samples:
|
||||
* - lang: curl
|
||||
* label: cURL
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ info:
|
||||
|
||||
## SSE Streaming
|
||||
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
||||
version: 1.7.34
|
||||
version: 1.7.36
|
||||
contact:
|
||||
name: sofarr
|
||||
license:
|
||||
|
||||
@@ -35,7 +35,7 @@ function normalizeTitle(str) {
|
||||
* 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 } = {}) {
|
||||
function titleMatches(clientName, arrTitle, { isFallback = true, caller = 'DownloadMatcher' } = {}) {
|
||||
if (!clientName || !arrTitle) return false;
|
||||
const a = clientName.toLowerCase();
|
||||
const b = arrTitle.toLowerCase();
|
||||
@@ -48,7 +48,7 @@ function titleMatches(clientName, arrTitle, { isFallback = true } = {}) {
|
||||
a.includes(bNorm) || bNorm.includes(a);
|
||||
|
||||
if (matched && isFallback) {
|
||||
logger.debug(`[DownloadMatcher] Title fallback match after normalization: "${clientName}" <-> "${arrTitle}"`);
|
||||
logger.debug(`[DownloadMatcher] Title fallback match in ${caller} after normalization: "${clientName}" <-> "${arrTitle}"`);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
@@ -56,6 +56,9 @@ function titleMatches(clientName, arrTitle, { isFallback = true } = {}) {
|
||||
/**
|
||||
* All callers (matchers + orphan path) must supply client, instanceId, and instanceName via options.
|
||||
* Defaults exist only as a last-resort safety net.
|
||||
*
|
||||
* @example
|
||||
* buildArrDownload(sonarrMatch, context, { client: 'sabnzbd', instanceId: 'sabnzbd-default', instanceName: 'SABnzbd' })
|
||||
*/
|
||||
function buildArrDownload(record, context, options = {}) {
|
||||
const {
|
||||
@@ -119,7 +122,6 @@ function buildArrDownload(record, context, options = {}) {
|
||||
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;
|
||||
@@ -131,6 +133,7 @@ function buildArrDownload(record, context, options = {}) {
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = options.downloadPath || null;
|
||||
dlObj.targetPath = media.path || null;
|
||||
dlObj.arrInstanceKey = record._instanceKey || null;
|
||||
dlObj.arrLink = isSeries
|
||||
? DownloadAssembler.getSonarrLink(media)
|
||||
: DownloadAssembler.getRadarrLink(media);
|
||||
@@ -251,13 +254,13 @@ async function matchSabSlots(slots, context) {
|
||||
if (!sonarrMatch) {
|
||||
sonarrMatch = sonarrQueueRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' });
|
||||
});
|
||||
}
|
||||
if (!radarrMatch) {
|
||||
radarrMatch = radarrQueueRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -265,13 +268,13 @@ async function matchSabSlots(slots, context) {
|
||||
if (!sonarrMatch) {
|
||||
sonarrMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' });
|
||||
});
|
||||
}
|
||||
if (!radarrMatch) {
|
||||
radarrMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -362,13 +365,13 @@ async function matchSabHistory(slots, context) {
|
||||
if (!sonarrMatch) {
|
||||
sonarrMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabHistory' });
|
||||
});
|
||||
}
|
||||
if (!radarrMatch) {
|
||||
radarrMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true });
|
||||
return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabHistory' });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -441,13 +444,13 @@ async function matchTorrents(torrents, context) {
|
||||
if (!sonarrMatch) {
|
||||
sonarrMatch = sonarrQueueRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true });
|
||||
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' });
|
||||
});
|
||||
}
|
||||
if (!radarrMatch) {
|
||||
radarrMatch = radarrQueueRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true });
|
||||
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -458,13 +461,13 @@ async function matchTorrents(torrents, context) {
|
||||
if (!sonarrHistoryMatch) {
|
||||
sonarrHistoryMatch = sonarrHistoryRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true });
|
||||
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' });
|
||||
});
|
||||
}
|
||||
if (!radarrHistoryMatch) {
|
||||
radarrHistoryMatch = radarrHistoryRecords.find(r => {
|
||||
const rTitle = r.title || r.sourceTitle;
|
||||
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true });
|
||||
return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ export default defineConfig({
|
||||
test: {
|
||||
// Global test helpers (describe, it, expect, vi) without per-file imports
|
||||
globals: true,
|
||||
// Increase test timeout to avoid transient timeouts under coverage/heavy loads
|
||||
testTimeout: 15000,
|
||||
// Run test files sequentially to avoid cross-test background event pollution
|
||||
fileParallelism: false,
|
||||
// Run each test file in an isolated module registry so module-level state
|
||||
// (tokenStore cache, config singletons) doesn't leak between files
|
||||
isolate: true,
|
||||
|
||||
Reference in New Issue
Block a user