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/).
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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sofarr",
"version": "1.7.5",
"version": "1.7.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sofarr",
"version": "1.7.5",
"version": "1.7.6",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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",
"main": "server/index.js",
"scripts": {
+1
View File
@@ -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' }
});
+9 -6
View File
@@ -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;
}
+1
View File
@@ -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' }
});
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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)`);
}
+7 -5
View File
@@ -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);
},
/**
+91
View File
@@ -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', () => {