Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ff660b8af | |||
| 6ac0a8421e | |||
| a021ceba47 | |||
| f8c7e35f31 | |||
| de71580756 |
@@ -4,6 +4,21 @@ 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.6] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bypass rate limiter for cover art** — Exempted `GET /api/dashboard/cover-art` requests from the global API rate limiter. Resolves Gitea Issue [#43](https://git.i3omb.com/Gandalf/sofarr/issues/43).
|
||||
- **Ombi request cache bypass** — Added a `force` cache-bypassing flag to `OmbiRetriever`'s cache refresh mechanism, enabling real-time cache updates upon receiving Ombi webhooks or manual request list reloads. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.5] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Ombi webhook settings persistence** — Fixed a bug where enabling the Ombi webhook from the frontend was successfully processed by the server but not stored on the Ombi side. The payload submitted to Ombi now retrieves the database `id` of the settings row first and merges it back into the `POST` payload. This ensures Entity Framework Core on the Ombi backend performs an update on the correct database row, enabling the webhook and letting its status persist successfully. Resolves Gitea Issue [#41](https://git.i3omb.com/Gandalf/sofarr/issues/41).
|
||||
|
||||
---
|
||||
|
||||
## [1.7.4] - 2026-05-23
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.4",
|
||||
"version": "1.7.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sofarr",
|
||||
"version": "1.7.4",
|
||||
"version": "1.7.6",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.7.4",
|
||||
"version": "1.7.6",
|
||||
"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": {
|
||||
|
||||
@@ -96,6 +96,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
||||
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
||||
message: { error: 'Too many requests, please try again later' }
|
||||
});
|
||||
|
||||
|
||||
@@ -87,10 +87,11 @@ class OmbiRetriever extends ArrRetriever {
|
||||
|
||||
/**
|
||||
* Refresh cached data from Ombi API
|
||||
* @param {boolean} force - Whether to force a refresh regardless of TTL
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async refreshCache() {
|
||||
if (!this.isCacheExpired()) {
|
||||
async refreshCache(force = false) {
|
||||
if (!force && !this.isCacheExpired()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,19 +142,21 @@ class OmbiRetriever extends ArrRetriever {
|
||||
|
||||
/**
|
||||
* Get all movie requests
|
||||
* @param {boolean} force - Whether to force refresh from API
|
||||
* @returns {Promise<Array>} Array of movie request objects
|
||||
*/
|
||||
async getMovieRequests() {
|
||||
await this.refreshCache();
|
||||
async getMovieRequests(force = false) {
|
||||
await this.refreshCache(force);
|
||||
return this.cache.movieRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all TV requests
|
||||
* @param {boolean} force - Whether to force refresh from API
|
||||
* @returns {Promise<Array>} Array of TV request objects
|
||||
*/
|
||||
async getTvRequests() {
|
||||
await this.refreshCache();
|
||||
async getTvRequests(force = false) {
|
||||
await this.refreshCache(force);
|
||||
return this.cache.tvRequests;
|
||||
}
|
||||
|
||||
|
||||
@@ -206,6 +206,7 @@ const apiLimiter = rateLimit({
|
||||
max: 300, // 300 requests per IP per window (generous for polling)
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
||||
message: { error: 'Too many requests, please try again later' }
|
||||
});
|
||||
|
||||
|
||||
+19
-1
@@ -114,7 +114,7 @@ router.get('/requests', requireAuth, async (req, res) => {
|
||||
// initialize() is idempotent - cheap no-op if already initialized
|
||||
await arrRetrieverRegistry.initialize();
|
||||
|
||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
|
||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||
|
||||
// Filter by user if not admin or if showAll is false
|
||||
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
|
||||
@@ -225,9 +225,27 @@ router.post('/webhook/enable', requireAuth, async (req, res) => {
|
||||
|
||||
// Call Ombi API to register webhook
|
||||
const axios = require('axios');
|
||||
|
||||
// Get existing settings to retrieve the database ID
|
||||
const currentRes = await axios.get(
|
||||
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
|
||||
{
|
||||
headers: {
|
||||
'ApiKey': ombiInst.apiKey
|
||||
}
|
||||
}
|
||||
).catch(err => {
|
||||
logToFile(`[Ombi] Warning fetching existing webhook settings: ${err.message}`);
|
||||
return { data: {} };
|
||||
});
|
||||
|
||||
const currentConfig = currentRes.data || {};
|
||||
const settingsId = currentConfig.id || 0;
|
||||
|
||||
const response = await axios.post(
|
||||
`${ombiInst.url}/api/v1/Settings/notifications/webhook`,
|
||||
{
|
||||
id: settingsId,
|
||||
enabled: true,
|
||||
webhookUrl: webhookUrl,
|
||||
applicationToken: ombiInst.apiKey
|
||||
|
||||
@@ -258,7 +258,7 @@ async function processWebhookEvent(serviceType, eventType) {
|
||||
const ombiInstances = getOmbiInstances();
|
||||
|
||||
if (affectsOmbi) {
|
||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
|
||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
|
||||
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
|
||||
}
|
||||
|
||||
@@ -322,9 +322,10 @@ const arrRetrieverRegistry = {
|
||||
|
||||
/**
|
||||
* Get all Ombi requests
|
||||
* @param {boolean} force - Whether to force refresh from API
|
||||
* @returns {Promise<Object>} Object with movie and TV request arrays
|
||||
*/
|
||||
async getOmbiRequests() {
|
||||
async getOmbiRequests(force = false) {
|
||||
const ombiRetrievers = this.getOmbiRetrievers();
|
||||
if (ombiRetrievers.length === 0) {
|
||||
return { movie: [], tv: [] };
|
||||
@@ -333,8 +334,8 @@ const arrRetrieverRegistry = {
|
||||
// Use the first Ombi retriever (single instance expected)
|
||||
const retriever = ombiRetrievers[0];
|
||||
try {
|
||||
const movieRequests = await retriever.getMovieRequests();
|
||||
const tvRequests = await retriever.getTvRequests();
|
||||
const movieRequests = await retriever.getMovieRequests(force);
|
||||
const tvRequests = await retriever.getTvRequests(false);
|
||||
return { movie: movieRequests, tv: tvRequests };
|
||||
} catch (error) {
|
||||
logToFile(`[ArrRetrieverRegistry] Error fetching Ombi requests: ${error.message}`);
|
||||
@@ -344,10 +345,11 @@ const arrRetrieverRegistry = {
|
||||
|
||||
/**
|
||||
* Get Ombi requests grouped by type
|
||||
* @param {boolean} force - Whether to force refresh from API
|
||||
* @returns {Promise<Object>} Requests grouped by type (movie, tv)
|
||||
*/
|
||||
async getOmbiRequestsByType() {
|
||||
return await this.getOmbiRequests();
|
||||
async getOmbiRequestsByType(force = false) {
|
||||
return await this.getOmbiRequests(force);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -850,7 +850,15 @@ describe('POST /api/ombi/webhook/enable', () => {
|
||||
|
||||
it('enables webhook successfully', async () => {
|
||||
nock(OMBI_BASE)
|
||||
.post('/api/v1/Settings/notifications/webhook')
|
||||
.get('/api/v1/Settings/notifications/webhook')
|
||||
.reply(200, { id: 42, enabled: false, webhookUrl: null, applicationToken: null });
|
||||
nock(OMBI_BASE)
|
||||
.post('/api/v1/Settings/notifications/webhook', {
|
||||
id: 42,
|
||||
enabled: true,
|
||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
|
||||
applicationToken: 'test-ombi-key'
|
||||
})
|
||||
.reply(200, { success: true });
|
||||
|
||||
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||
@@ -866,7 +874,34 @@ describe('POST /api/ombi/webhook/enable', () => {
|
||||
expect(res.body.applicationToken).toBe('test-ombi-key');
|
||||
});
|
||||
|
||||
it('enables webhook successfully even if GET settings fails', async () => {
|
||||
nock(OMBI_BASE)
|
||||
.get('/api/v1/Settings/notifications/webhook')
|
||||
.reply(500, { error: 'Failed to fetch settings' });
|
||||
nock(OMBI_BASE)
|
||||
.post('/api/v1/Settings/notifications/webhook', {
|
||||
id: 0,
|
||||
enabled: true,
|
||||
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
|
||||
applicationToken: 'test-ombi-key'
|
||||
})
|
||||
.reply(200, { success: true });
|
||||
|
||||
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/ombi/webhook/enable')
|
||||
.set('Cookie', cookies)
|
||||
.set('X-CSRF-Token', csrfToken)
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('handles Ombi API errors gracefully', async () => {
|
||||
nock(OMBI_BASE)
|
||||
.get('/api/v1/Settings/notifications/webhook')
|
||||
.reply(200, { id: 42 });
|
||||
nock(OMBI_BASE)
|
||||
.post('/api/v1/Settings/notifications/webhook')
|
||||
.reply(500, { error: 'Internal server error' });
|
||||
|
||||
@@ -266,6 +266,39 @@ describe('OmbiRetriever', () => {
|
||||
expect(retriever.cache.movieRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should refresh if cache is not expired but force is true', async () => {
|
||||
const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
|
||||
const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }];
|
||||
const mockTvShows = [];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies1);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
|
||||
// First refresh
|
||||
await retriever.refreshCache();
|
||||
expect(retriever.cache.movieRequests).toHaveLength(1);
|
||||
|
||||
// Set up new mocks for second refresh without advancing time
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies2);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
// Second refresh with force=true should make API calls
|
||||
await retriever.refreshCache(true);
|
||||
expect(retriever.cache.movieRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should build movie map with TMDB and IMDB IDs', async () => {
|
||||
const mockMovies = [
|
||||
{ id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' },
|
||||
@@ -372,6 +405,35 @@ describe('OmbiRetriever', () => {
|
||||
|
||||
expect(result).toEqual(mockMovies);
|
||||
});
|
||||
|
||||
it('should force refresh and return movie requests even when cache is not expired if force is true', async () => {
|
||||
const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
|
||||
const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }];
|
||||
const mockTvShows = [];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies1);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
await retriever.refreshCache();
|
||||
|
||||
// Set up new mocks for second fetch
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies2);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows);
|
||||
|
||||
const result = await retriever.getMovieRequests(true);
|
||||
expect(result).toEqual(mockMovies2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTvRequests', () => {
|
||||
@@ -414,6 +476,35 @@ describe('OmbiRetriever', () => {
|
||||
|
||||
expect(result).toEqual(mockTvShows);
|
||||
});
|
||||
|
||||
it('should force refresh and return TV requests even when cache is not expired if force is true', async () => {
|
||||
const mockMovies = [];
|
||||
const mockTvShows1 = [{ id: 1, title: 'Show 1', theTvDbId: '11111' }];
|
||||
const mockTvShows2 = [{ id: 1, title: 'Show 1' }, { id: 2, title: 'Show 2', theTvDbId: '22222' }];
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows1);
|
||||
|
||||
const retriever = new OmbiRetriever(instanceConfig);
|
||||
await retriever.refreshCache();
|
||||
|
||||
// Set up new mocks for second fetch
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/movie')
|
||||
.reply(200, mockMovies);
|
||||
|
||||
nock(baseUrl)
|
||||
.get('/api/v1/Request/tv')
|
||||
.reply(200, mockTvShows2);
|
||||
|
||||
const result = await retriever.getTvRequests(true);
|
||||
expect(result).toEqual(mockTvShows2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMovieRequest', () => {
|
||||
|
||||
Reference in New Issue
Block a user