Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d29b6e9223 | |||
| df5328349b | |||
| b9c8c0be87 | |||
| 06818dbf29 |
@@ -4,6 +4,16 @@ 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).
|
||||
|
||||
## [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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.33",
|
||||
"version": "1.7.34",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sofarr",
|
||||
"version": "1.7.33",
|
||||
"version": "1.7.34",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.33",
|
||||
"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",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
|
||||
+18
-6
@@ -170,8 +170,12 @@
|
||||
|
||||
<div class="tab-panel" id="tab-downloads">
|
||||
<div class="downloads-container">
|
||||
<div class="downloads-header">
|
||||
<div class="downloads-controls">
|
||||
<div class="downloads-header tab-header">
|
||||
<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>
|
||||
<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">
|
||||
@@ -200,8 +204,12 @@
|
||||
|
||||
<div class="tab-panel hidden" id="tab-requests">
|
||||
<div class="requests-container">
|
||||
<div class="requests-header">
|
||||
<div class="requests-controls">
|
||||
<div class="requests-header tab-header">
|
||||
<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 -->
|
||||
<div class="request-filter" id="request-type-filter">
|
||||
<label class="request-filter-label">Type:</label>
|
||||
@@ -286,8 +294,12 @@
|
||||
|
||||
<div class="tab-panel hidden" id="tab-history">
|
||||
<div class="history-container" id="history-container">
|
||||
<div class="history-header">
|
||||
<div class="history-controls">
|
||||
<div class="history-header tab-header">
|
||||
<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>
|
||||
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
|
||||
<span class="history-days-label">days</span>
|
||||
|
||||
+53
-32
@@ -689,15 +689,61 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Downloads header and controls */
|
||||
.downloads-header {
|
||||
/* Unified Tab Headers (Issue #72) */
|
||||
.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;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -898,11 +944,7 @@ body {
|
||||
/* ===== Request Filters ===== */
|
||||
|
||||
.requests-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
/* Inherits from .tab-header */
|
||||
}
|
||||
|
||||
.requests-controls {
|
||||
@@ -1076,18 +1118,7 @@ body {
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
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;
|
||||
/* Inherits from .tab-header */
|
||||
}
|
||||
|
||||
.history-controls {
|
||||
@@ -1134,7 +1165,7 @@ body {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
@@ -2237,17 +2268,7 @@ body {
|
||||
}
|
||||
|
||||
.requests-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.requests-header h2 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.5rem;
|
||||
/* Inherits from .tab-header */
|
||||
}
|
||||
|
||||
.no-requests {
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
* version:
|
||||
* type: string
|
||||
* description: sofarr version
|
||||
* example: "1.7.33"
|
||||
* example: "1.7.34"
|
||||
* 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.33
|
||||
version: 1.7.34
|
||||
contact:
|
||||
name: sofarr
|
||||
license:
|
||||
|
||||
@@ -476,7 +476,10 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
||||
// Content-aware replay key components (Issue #62)
|
||||
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'})`);
|
||||
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)
|
||||
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'})`);
|
||||
return res.status(200).json({ received: true, duplicate: true });
|
||||
}
|
||||
|
||||
@@ -260,19 +260,10 @@ async function csrfHeaders(app) {
|
||||
// Environment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeAll(() => {
|
||||
beforeEach(() => {
|
||||
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.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();
|
||||
});
|
||||
|
||||
@@ -280,6 +271,9 @@ afterEach(() => {
|
||||
nock.cleanAll();
|
||||
invalidatePollCache();
|
||||
cache.invalidate('emby:users');
|
||||
delete process.env.EMBY_URL;
|
||||
delete process.env.SONARR_INSTANCES;
|
||||
delete process.env.RADARR_INSTANCES;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -387,6 +387,38 @@ describe('Replay protection', () => {
|
||||
expect(first.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();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user