Compare commits

..

2 Commits

Author SHA1 Message Date
gronod 6ff660b8af merge branch 'develop' into 'main' - Release v1.7.6
Create Release / release (push) Successful in 23s
Build and Push Docker Image / build (push) Successful in 1m35s
CI / Tests & coverage (push) Successful in 2m46s
CI / Security audit (push) Successful in 1m41s
CI / Swagger Validation & Coverage (push) Successful in 2m22s
2026-05-23 18:55:18 +01:00
gronod 6ac0a8421e fix: resolve rate-limiting and Ombi requests caching bugs (fixes #42, fixes #43)
Build and Push Docker Image / build (push) Successful in 1m34s
Docs Check / Markdown lint (push) Successful in 2m14s
CI / Security audit (push) Successful in 2m30s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m40s
CI / Swagger Validation & Coverage (push) Successful in 3m22s
Docs Check / Mermaid diagram parse check (push) Successful in 3m43s
CI / Tests & coverage (push) Successful in 3m59s
2026-05-23 18:55:03 +01:00
10 changed files with 121 additions and 16 deletions
+7
View File
@@ -4,6 +4,13 @@ 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.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 ## [1.7.5] - 2026-05-23
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.7.5", "version": "1.7.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sofarr", "name": "sofarr",
"version": "1.7.5", "version": "1.7.6",
"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.5", "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", "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": {
+1
View File
@@ -96,6 +96,7 @@ function createApp({ skipRateLimits = false } = {}) {
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300, max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
message: { error: 'Too many requests, please try again later' } message: { error: 'Too many requests, please try again later' }
}); });
+9 -6
View File
@@ -87,10 +87,11 @@ class OmbiRetriever extends ArrRetriever {
/** /**
* Refresh cached data from Ombi API * Refresh cached data from Ombi API
* @param {boolean} force - Whether to force a refresh regardless of TTL
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async refreshCache() { async refreshCache(force = false) {
if (!this.isCacheExpired()) { if (!force && !this.isCacheExpired()) {
return; return;
} }
@@ -141,19 +142,21 @@ class OmbiRetriever extends ArrRetriever {
/** /**
* Get all movie requests * Get all movie requests
* @param {boolean} force - Whether to force refresh from API
* @returns {Promise<Array>} Array of movie request objects * @returns {Promise<Array>} Array of movie request objects
*/ */
async getMovieRequests() { async getMovieRequests(force = false) {
await this.refreshCache(); await this.refreshCache(force);
return this.cache.movieRequests; return this.cache.movieRequests;
} }
/** /**
* Get all TV requests * Get all TV requests
* @param {boolean} force - Whether to force refresh from API
* @returns {Promise<Array>} Array of TV request objects * @returns {Promise<Array>} Array of TV request objects
*/ */
async getTvRequests() { async getTvRequests(force = false) {
await this.refreshCache(); await this.refreshCache(force);
return this.cache.tvRequests; return this.cache.tvRequests;
} }
+1
View File
@@ -206,6 +206,7 @@ const apiLimiter = rateLimit({
max: 300, // 300 requests per IP per window (generous for polling) max: 300, // 300 requests per IP per window (generous for polling)
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
message: { error: 'Too many requests, please try again later' } message: { error: 'Too many requests, please try again later' }
}); });
+1 -1
View File
@@ -114,7 +114,7 @@ router.get('/requests', requireAuth, async (req, res) => {
// initialize() is idempotent - cheap no-op if already initialized // initialize() is idempotent - cheap no-op if already initialized
await arrRetrieverRegistry.initialize(); 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 // Filter by user if not admin or if showAll is false
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll); const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
+1 -1
View File
@@ -258,7 +258,7 @@ async function processWebhookEvent(serviceType, eventType) {
const ombiInstances = getOmbiInstances(); const ombiInstances = getOmbiInstances();
if (affectsOmbi) { if (affectsOmbi) {
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(); const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL); 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)`); logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
} }
+7 -5
View File
@@ -322,9 +322,10 @@ const arrRetrieverRegistry = {
/** /**
* Get all Ombi requests * Get all Ombi requests
* @param {boolean} force - Whether to force refresh from API
* @returns {Promise<Object>} Object with movie and TV request arrays * @returns {Promise<Object>} Object with movie and TV request arrays
*/ */
async getOmbiRequests() { async getOmbiRequests(force = false) {
const ombiRetrievers = this.getOmbiRetrievers(); const ombiRetrievers = this.getOmbiRetrievers();
if (ombiRetrievers.length === 0) { if (ombiRetrievers.length === 0) {
return { movie: [], tv: [] }; return { movie: [], tv: [] };
@@ -333,8 +334,8 @@ const arrRetrieverRegistry = {
// Use the first Ombi retriever (single instance expected) // Use the first Ombi retriever (single instance expected)
const retriever = ombiRetrievers[0]; const retriever = ombiRetrievers[0];
try { try {
const movieRequests = await retriever.getMovieRequests(); const movieRequests = await retriever.getMovieRequests(force);
const tvRequests = await retriever.getTvRequests(); const tvRequests = await retriever.getTvRequests(false);
return { movie: movieRequests, tv: tvRequests }; return { movie: movieRequests, tv: tvRequests };
} catch (error) { } catch (error) {
logToFile(`[ArrRetrieverRegistry] Error fetching Ombi requests: ${error.message}`); logToFile(`[ArrRetrieverRegistry] Error fetching Ombi requests: ${error.message}`);
@@ -344,10 +345,11 @@ const arrRetrieverRegistry = {
/** /**
* Get Ombi requests grouped by type * Get Ombi requests grouped by type
* @param {boolean} force - Whether to force refresh from API
* @returns {Promise<Object>} Requests grouped by type (movie, tv) * @returns {Promise<Object>} Requests grouped by type (movie, tv)
*/ */
async getOmbiRequestsByType() { async getOmbiRequestsByType(force = false) {
return await this.getOmbiRequests(); return await this.getOmbiRequests(force);
}, },
/** /**
+91
View File
@@ -266,6 +266,39 @@ describe('OmbiRetriever', () => {
expect(retriever.cache.movieRequests).toHaveLength(2); 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 () => { it('should build movie map with TMDB and IMDB IDs', async () => {
const mockMovies = [ const mockMovies = [
{ id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' }, { id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' },
@@ -372,6 +405,35 @@ describe('OmbiRetriever', () => {
expect(result).toEqual(mockMovies); 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', () => { describe('getTvRequests', () => {
@@ -414,6 +476,35 @@ describe('OmbiRetriever', () => {
expect(result).toEqual(mockTvShows); 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', () => { describe('findMovieRequest', () => {