diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b40ff7..60a6be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index df42842..b523091 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index bf4136b..c8297ee 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/server/app.js b/server/app.js index 4aa8ac3..d625c02 100644 --- a/server/app.js +++ b/server/app.js @@ -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' } }); diff --git a/server/clients/OmbiRetriever.js b/server/clients/OmbiRetriever.js index bb72f41..9ae5ba7 100644 --- a/server/clients/OmbiRetriever.js +++ b/server/clients/OmbiRetriever.js @@ -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} */ - 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 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 of TV request objects */ - async getTvRequests() { - await this.refreshCache(); + async getTvRequests(force = false) { + await this.refreshCache(force); return this.cache.tvRequests; } diff --git a/server/index.js b/server/index.js index 0080e02..f4536ef 100644 --- a/server/index.js +++ b/server/index.js @@ -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' } }); diff --git a/server/routes/ombi.js b/server/routes/ombi.js index 2772d9d..cf0a88d 100644 --- a/server/routes/ombi.js +++ b/server/routes/ombi.js @@ -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); diff --git a/server/routes/webhook.js b/server/routes/webhook.js index cc39b28..1283f19 100644 --- a/server/routes/webhook.js +++ b/server/routes/webhook.js @@ -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)`); } diff --git a/server/utils/arrRetrievers.js b/server/utils/arrRetrievers.js index d0c6040..4bc6b86 100644 --- a/server/utils/arrRetrievers.js +++ b/server/utils/arrRetrievers.js @@ -322,9 +322,10 @@ const arrRetrieverRegistry = { /** * Get all Ombi requests + * @param {boolean} force - Whether to force refresh from API * @returns {Promise} 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} Requests grouped by type (movie, tv) */ - async getOmbiRequestsByType() { - return await this.getOmbiRequests(); + async getOmbiRequestsByType(force = false) { + return await this.getOmbiRequests(force); }, /** diff --git a/tests/unit/clients/OmbiRetriever.test.js b/tests/unit/clients/OmbiRetriever.test.js index d0c054c..80407c0 100644 --- a/tests/unit/clients/OmbiRetriever.test.js +++ b/tests/unit/clients/OmbiRetriever.test.js @@ -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', () => {