Compare commits

...

8 Commits

Author SHA1 Message Date
gronod d29b6e9223 merge branch 'develop' into 'main' - Release v1.7.34
Create Release / release (push) Successful in 38s
Build and Push Docker Image / build (push) Successful in 2m38s
2026-05-28 18:15:33 +01:00
gronod df5328349b chore: bump version to 1.7.34 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m46s
Docs Check / Markdown lint (push) Successful in 2m19s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m53s
CI / Security audit (push) Successful in 3m23s
Docs Check / Mermaid diagram parse check (push) Successful in 3m55s
CI / Swagger Validation & Coverage (push) Successful in 4m18s
CI / Tests & coverage (push) Successful in 4m36s
2026-05-28 18:15:27 +01:00
gronod b9c8c0be87 style(ui): unify tab headers layout, typography, and icons (closes #72)
Build and Push Docker Image / build (push) Successful in 1m28s
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
CI / Swagger Validation & Coverage (push) Has been cancelled
2026-05-28 18:12:53 +01:00
gronod 06818dbf29 fix(webhooks): skip replay protection for Test events (closes #71)
Build and Push Docker Image / build (push) Has been cancelled
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
CI / Swagger Validation & Coverage (push) Has been cancelled
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m56s
2026-05-28 18:11:45 +01:00
gronod 7f7a91f056 merge branch 'develop' into 'main' - Release v1.7.33
CI / Tests & coverage (push) Successful in 2m53s
Create Release / release (push) Successful in 17s
CI / Security audit (push) Successful in 3m8s
Build and Push Docker Image / build (push) Successful in 1m11s
CI / Swagger Validation & Coverage (push) Successful in 2m23s
2026-05-28 17:42:54 +01:00
gronod 1dc8d8a26c chore: bump version to 1.7.33 and update CHANGELOG and docs
Docs Check / Markdown lint (push) Successful in 2m4s
Build and Push Docker Image / build (push) Successful in 2m16s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m31s
CI / Security audit (push) Successful in 2m57s
Docs Check / Mermaid diagram parse check (push) Successful in 3m35s
CI / Swagger Validation & Coverage (push) Successful in 4m5s
CI / Tests & coverage (push) Successful in 4m6s
2026-05-28 17:42:43 +01:00
gronod af33e4ec43 feat(ui): align requests container and cards layout with downloads/history (closes #69) 2026-05-28 17:41:59 +01:00
gronod a4d398ef1b fix(webhooks): correct Ombi isReplay() call after signature change (closes #70) 2026-05-28 17:40:33 +01:00
12 changed files with 174 additions and 72 deletions
+20
View File
@@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 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.34] - 2026-05-28
### Fixed
- **Webhook Test Duplicate Error (Issue #71)** — Skipped duplicate/replay protection for `"Test"` event types in Sonarr and Radarr webhook handlers, resolving test button failures. Resolves Gitea Issue [#71](https://git.i3omb.com/Gandalf/sofarr/issues/71).
### Enhanced
- **Unified Tab Header Layout & Typography Consistency (Issue #72)** — Added consistent titles, subtitles, and icons across Active Downloads, Recently Completed, and Requests tab panels, and refactored styling to use a unified flexbox design with CSS variables. Resolves Gitea Issue [#72](https://git.i3omb.com/Gandalf/sofarr/issues/72).
## [1.7.33] - 2026-05-28
### Added
- **Requests Tab Layout Enhancement (Issue #69)** — Redesigned and unified the Requests tab container and card layouts with the Active Downloads and Recently Completed tabs. Added styled media-type borders (`tv` and `movie`) using system color variables, styled the `.requests-container` with a surface card background (`var(--surface)`) and box shadow, converted `.requests-list` to a column flexbox (`display: flex; flex-direction: column; gap: 8px;`), aligned card items to the top (`align-items: flex-start`), tighter padding (`10px 14px`), and border-radius (`6px`), and scaled `.request-type-icon` to `48px` wide and `68px` high as a clean cover-art placeholder. All changes are strictly scoped to the requests tab element selectors, leaving active and recent downloads 100% untouched. Resolves Gitea Issue [#69](https://git.i3omb.com/Gandalf/sofarr/issues/69).
### Fixed
- **Webhook Regression (Issue #70)** — Fixed a critical regression introduced in #62 where the Ombi webhook handler called `isReplay()` with 3 arguments instead of the new 4-argument signature (`eventType, instanceName, eventDate, contentId`). The handler now correctly passes `requestId` as the fourth `contentId` argument. This restores reliability to real Ombi webhooks, loopback fallbacks, and the Ombi test simulation buttons. Resolves Gitea Issue [#70](https://git.i3omb.com/Gandalf/sofarr/issues/70).
## [1.7.32] - 2026-05-28 ## [1.7.32] - 2026-05-28
### Fixed ### Fixed
+1 -1
View File
@@ -119,7 +119,7 @@ function createRequestCard(request) {
} }
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'request-card'; card.className = `request-card ${request.mediaType || ''}`;
const typeIcon = document.createElement('span'); const typeIcon = document.createElement('span');
typeIcon.className = `request-type-icon ${request.mediaType || ''}`; typeIcon.className = `request-type-icon ${request.mediaType || ''}`;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.7.32", "version": "1.7.34",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sofarr", "name": "sofarr",
"version": "1.7.32", "version": "1.7.34",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.7.32", "version": "1.7.34",
"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", "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", "main": "server/index.js",
"scripts": { "scripts": {
+4 -4
View File
File diff suppressed because one or more lines are too long
+18 -6
View File
@@ -170,8 +170,12 @@
<div class="tab-panel" id="tab-downloads"> <div class="tab-panel" id="tab-downloads">
<div class="downloads-container"> <div class="downloads-container">
<div class="downloads-header"> <div class="downloads-header tab-header">
<div class="downloads-controls"> <div class="tab-header-title">
<h2><span class="tab-header-icon">📥</span> Active Downloads</h2>
<p class="tab-header-subtitle">Track and manage your active media downloads in real-time</p>
</div>
<div class="downloads-controls tab-header-controls">
<label class="download-client-label" for="download-client-filter">Download client:</label> <label class="download-client-label" for="download-client-filter">Download client:</label>
<div class="download-client-filter" id="download-client-filter"> <div class="download-client-filter" id="download-client-filter">
<button class="download-client-dropdown-btn" id="download-client-dropdown-btn" type="button" aria-expanded="false"> <button class="download-client-dropdown-btn" id="download-client-dropdown-btn" type="button" aria-expanded="false">
@@ -200,8 +204,12 @@
<div class="tab-panel hidden" id="tab-requests"> <div class="tab-panel hidden" id="tab-requests">
<div class="requests-container"> <div class="requests-container">
<div class="requests-header"> <div class="requests-header tab-header">
<div class="requests-controls"> <div class="tab-header-title">
<h2><span class="tab-header-icon">📨</span> Requests</h2>
<p class="tab-header-subtitle">Browse, filter, and track requests synced from Ombi</p>
</div>
<div class="requests-controls tab-header-controls">
<!-- Media Type Filter --> <!-- Media Type Filter -->
<div class="request-filter" id="request-type-filter"> <div class="request-filter" id="request-type-filter">
<label class="request-filter-label">Type:</label> <label class="request-filter-label">Type:</label>
@@ -286,8 +294,12 @@
<div class="tab-panel hidden" id="tab-history"> <div class="tab-panel hidden" id="tab-history">
<div class="history-container" id="history-container"> <div class="history-container" id="history-container">
<div class="history-header"> <div class="history-header tab-header">
<div class="history-controls"> <div class="tab-header-title">
<h2><span class="tab-header-icon">📜</span> Recently Completed</h2>
<p class="tab-header-subtitle">Review successful imports and troubleshoot failed upgrade attempts</p>
</div>
<div class="history-controls tab-header-controls">
<label class="history-days-label" for="history-days">Last</label> <label class="history-days-label" for="history-days">Last</label>
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90"> <input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
<span class="history-days-label">days</span> <span class="history-days-label">days</span>
+80 -42
View File
@@ -689,15 +689,61 @@ body {
padding: 0; padding: 0;
} }
/* Downloads header and controls */ /* Unified Tab Headers (Issue #72) */
.downloads-header { .tab-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.tab-header-title {
display: flex;
flex-direction: column;
gap: 4px;
}
.tab-header-title h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.tab-header-icon {
font-size: 1.3rem;
display: inline-flex;
align-items: center;
}
.tab-header-subtitle {
margin: 0;
font-size: 0.8rem;
color: var(--text-muted);
}
.tab-header-controls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.tab-header, .tab-header-title h2, .tab-header-subtitle {
transition: color 0.3s, border-color 0.3s;
}
.downloads-header {
/* Inherits from .tab-header */
}
.downloads-controls { .downloads-controls {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -898,11 +944,7 @@ body {
/* ===== Request Filters ===== */ /* ===== Request Filters ===== */
.requests-header { .requests-header {
display: flex; /* Inherits from .tab-header */
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
} }
.requests-controls { .requests-controls {
@@ -1076,18 +1118,7 @@ body {
} }
.history-header { .history-header {
display: flex; /* Inherits from .tab-header */
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.history-header h2 {
margin: 0;
font-size: 1.2rem;
color: var(--text-primary);
flex: 1 1 auto;
} }
.history-controls { .history-controls {
@@ -1134,7 +1165,7 @@ body {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
font-size: 0.82rem; font-size: 0.85rem;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
@@ -2229,17 +2260,15 @@ body {
/* ===== Requests Tab ===== */ /* ===== Requests Tab ===== */
.requests-container { .requests-container {
padding: 20px; background: var(--surface);
padding: 16px 20px;
border-radius: 8px;
box-shadow: 0 2px 4px var(--shadow);
transition: background 0.3s;
} }
.requests-header { .requests-header {
margin-bottom: 20px; /* Inherits from .tab-header */
}
.requests-header h2 {
margin: 0;
color: var(--text-primary);
font-size: 1.5rem;
} }
.no-requests { .no-requests {
@@ -2249,37 +2278,46 @@ body {
} }
.requests-list { .requests-list {
display: grid; display: flex;
gap: 12px; flex-direction: column;
overflow-x: hidden; gap: 8px;
} }
.request-card { .request-card {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 12px; gap: 12px;
padding: 16px; padding: 10px 14px;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 6px;
transition: box-shadow 0.2s ease, border-color 0.2s ease; transition: box-shadow 0.2s ease, border-color 0.2s ease;
min-width: 0; min-width: 0;
} }
.request-card.tv {
border-left: 3px solid var(--series-color);
}
.request-card.movie {
border-left: 3px solid var(--movie-color);
}
.request-card:hover { .request-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-color: var(--accent); border-color: var(--accent);
} }
.request-type-icon { .request-type-icon {
font-size: 1.5rem; font-size: 1.6rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 40px; width: 48px;
height: 40px; height: 68px;
background: var(--surface-alt); background: var(--surface-alt);
border-radius: 8px; border-radius: 4px;
box-shadow: 0 1px 4px var(--shadow-strong);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -2289,11 +2327,11 @@ body {
} }
.request-title { .request-title {
font-size: 0.95rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 4px; margin: 0 0 4px;
overflow: hidden; word-break: break-word;
text-overflow: ellipsis;
} }
.request-meta { .request-meta {
+1 -1
View File
@@ -133,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) {
* version: * version:
* type: string * type: string
* description: sofarr version * description: sofarr version
* example: "1.7.32" * example: "1.7.34"
* x-code-samples: * x-code-samples:
* - lang: curl * - lang: curl
* label: cURL * label: cURL
+1 -1
View File
@@ -22,7 +22,7 @@ info:
## SSE Streaming ## SSE Streaming
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream. Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
version: 1.7.32 version: 1.7.34
contact: contact:
name: sofarr name: sofarr
license: license:
+10 -4
View File
@@ -476,7 +476,10 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
// Content-aware replay key components (Issue #62) // Content-aware replay key components (Issue #62)
const contentId = req.body.downloadId || req.body.series?.id || null; const contentId = req.body.downloadId || req.body.series?.id || null;
if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) { // Skip replay protection for Test events
if (eventType === "Test") {
logToFile(`[Webhook] Sonarr Test event received — skipping replay protection`);
} else if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) {
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`); logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`);
return res.status(200).json({ received: true, duplicate: true }); return res.status(200).json({ received: true, duplicate: true });
} }
@@ -637,7 +640,10 @@ router.post('/radarr', webhookLimiter, (req, res) => {
// Content-aware replay key components (Issue #62) // Content-aware replay key components (Issue #62)
const contentId = req.body.downloadId || req.body.movie?.id || null; const contentId = req.body.downloadId || req.body.movie?.id || null;
if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) { // Skip replay protection for Test events
if (eventType === "Test") {
logToFile(`[Webhook] Radarr Test event received — skipping replay protection`);
} else if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) {
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`); logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`);
return res.status(200).json({ received: true, duplicate: true }); return res.status(200).json({ received: true, duplicate: true });
} }
@@ -813,10 +819,10 @@ router.post('/ombi', webhookLimiter, (req, res) => {
// Use applicationUrl as instance identifier for replay protection // Use applicationUrl as instance identifier for replay protection
const instanceName = applicationUrl || 'ombi'; const instanceName = applicationUrl || 'ombi';
// Use requestId + eventType + current time as replay key
const eventDate = req.body.requestedDate || req.body.RequestedDate || new Date().toISOString(); const eventDate = req.body.requestedDate || req.body.RequestedDate || new Date().toISOString();
const contentId = requestId || null;
if (isReplay(eventType, instanceName, `${requestId}-${eventDate}`)) { if (isReplay(eventType, instanceName, eventDate, contentId)) {
logToFile(`[Webhook] Ombi duplicate event ignored: ${eventType} requestId=${requestId}`); logToFile(`[Webhook] Ombi duplicate event ignored: ${eventType} requestId=${requestId}`);
return res.status(200).json({ received: true, duplicate: true }); return res.status(200).json({ received: true, duplicate: true });
} }
+4 -10
View File
@@ -260,19 +260,10 @@ async function csrfHeaders(app) {
// Environment // Environment
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
beforeAll(() => { beforeEach(() => {
process.env.EMBY_URL = EMBY_BASE; process.env.EMBY_URL = EMBY_BASE;
process.env.SONARR_INSTANCES = JSON.stringify([{ id: 'sonarr-1', name: 'Main Sonarr', url: SONARR_BASE, apiKey: 'sk' }]); process.env.SONARR_INSTANCES = JSON.stringify([{ id: 'sonarr-1', name: 'Main Sonarr', url: SONARR_BASE, apiKey: 'sk' }]);
process.env.RADARR_INSTANCES = JSON.stringify([{ id: 'radarr-1', name: 'Main Radarr', url: RADARR_BASE, apiKey: 'rk' }]); process.env.RADARR_INSTANCES = JSON.stringify([{ id: 'radarr-1', name: 'Main Radarr', url: RADARR_BASE, apiKey: 'rk' }]);
});
afterAll(() => {
delete process.env.EMBY_URL;
delete process.env.SONARR_INSTANCES;
delete process.env.RADARR_INSTANCES;
});
beforeEach(() => {
seedEmptyCache(); seedEmptyCache();
}); });
@@ -280,6 +271,9 @@ afterEach(() => {
nock.cleanAll(); nock.cleanAll();
invalidatePollCache(); invalidatePollCache();
cache.invalidate('emby:users'); cache.invalidate('emby:users');
delete process.env.EMBY_URL;
delete process.env.SONARR_INSTANCES;
delete process.env.RADARR_INSTANCES;
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+32
View File
@@ -387,6 +387,38 @@ describe('Replay protection', () => {
expect(first.body.duplicate).toBeUndefined(); expect(first.body.duplicate).toBeUndefined();
expect(second.body.duplicate).toBeUndefined(); expect(second.body.duplicate).toBeUndefined();
}); });
it('sonarr: Test events bypass replay protection and are not flagged as duplicates', async () => {
const app = makeApp();
const payload = {
eventType: 'Test',
instanceName: 'Main Sonarr',
date: '2026-05-19T13:00:00.000Z'
};
const first = await postSonarr(app, payload);
expect(first.status).toBe(200);
expect(first.body.duplicate).toBeUndefined();
const second = await postSonarr(app, payload);
expect(second.status).toBe(200);
expect(second.body.duplicate).toBeUndefined();
});
it('radarr: Test events bypass replay protection and are not flagged as duplicates', async () => {
const app = makeApp();
const payload = {
eventType: 'Test',
instanceName: 'Main Radarr',
date: '2026-05-19T13:00:00.000Z'
};
const first = await postRadarr(app, payload);
expect(first.status).toBe(200);
expect(first.body.duplicate).toBeUndefined();
const second = await postRadarr(app, payload);
expect(second.status).toBe(200);
expect(second.body.duplicate).toBeUndefined();
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------