From 33b122d22b3b73e46ce70531be47393cf690db79 Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 27 May 2026 21:13:17 +0100 Subject: [PATCH] fix(ombi): resolve TV request status, user, and date display (Issue #53) Ombi's TV API nests all request data (requestedUser, approved, available, denied, requested, requestedDate) inside childRequests[] sub-objects. The application previously only inspected top-level properties, causing TV shows to consistently display 'unknown' status, 'unknown' user, and no request date. Changes: - OmbiRetriever._hydrateRequest(): hydrate requestedUser on each childRequests entry and promote requestedDate to top level - getRequestStatus() (server + client): aggregate status flags from childRequests[] when top-level properties are absent - Client date display: fallback to childRequests[0].requestedDate - Add 18 unit tests covering childRequests hydration, status aggregation, and date promotion Closes #53 --- CHANGELOG.md | 9 ++ client/src/ui/requests.js | 3 +- client/src/utils/ombiFilters.js | 17 ++++ package-lock.json | 4 +- package.json | 2 +- server/app.js | 2 +- server/clients/OmbiRetriever.js | 53 +++++++++-- server/index.js | 2 +- server/utils/ombiFilters.js | 17 ++++ tests/unit/arrRetrievers.test.js | 149 +++++++++++++++++++++++++++++++ tests/unit/ombiFilters.test.js | 34 +++++++ tests/unit/ombiHelpers.test.js | 28 ++++++ 12 files changed, 309 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44b9988..7f4dd80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ 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.25] - 2026-05-27 + +### Fixed + +- **Ombi TV Request Status, User, and Date Resolution (Issue #53)** — Resolved the root cause where Ombi TV show requests consistently displayed "unknown" status, "unknown" user, and missing request dates. The Ombi API nests all TV request data (`requestedUser`, `approved`, `available`, `denied`, `requested`, `requestedDate`) inside `childRequests[]` sub-objects, while the application previously only inspected top-level properties. + - `OmbiRetriever._hydrateRequest()` now hydrates `requestedUser` on each `childRequests` entry and promotes `requestedDate` from `childRequests[0]` to the top level. + - `getRequestStatus()` (server and client) now aggregates status flags from `childRequests[]` when top-level properties are absent. + - Client-side date display now falls back to `childRequests[0].requestedDate` as a defensive measure. + ## [1.7.24] - 2026-05-27 ### Enhanced diff --git a/client/src/ui/requests.js b/client/src/ui/requests.js index bf7152e..a73e250 100644 --- a/client/src/ui/requests.js +++ b/client/src/ui/requests.js @@ -158,7 +158,8 @@ function createRequestCard(request) { } meta.appendChild(user); - const dateStr = request.requestedDate || request.RequestedDate || request.date || request.Date; + const childDate = request.childRequests && request.childRequests[0] ? (request.childRequests[0].requestedDate || request.childRequests[0].RequestedDate) : null; + const dateStr = request.requestedDate || request.RequestedDate || request.date || request.Date || childDate; if (dateStr) { const requestDate = document.createElement('span'); requestDate.className = 'request-date'; diff --git a/client/src/utils/ombiFilters.js b/client/src/utils/ombiFilters.js index 06458e7..3cb491e 100644 --- a/client/src/utils/ombiFilters.js +++ b/client/src/utils/ombiFilters.js @@ -17,6 +17,23 @@ export function getRequestStatus(request) { if (request.denied) return 'denied'; if (request.approved) return 'approved'; if (request.requested) return 'pending'; + + // Ombi TV requests store status flags inside childRequests + if (Array.isArray(request.childRequests) && request.childRequests.length > 0) { + for (const child of request.childRequests) { + if (child && child.available) return 'available'; + } + for (const child of request.childRequests) { + if (child && child.denied) return 'denied'; + } + for (const child of request.childRequests) { + if (child && child.approved) return 'approved'; + } + for (const child of request.childRequests) { + if (child && child.requested) return 'pending'; + } + } + return 'unknown'; } diff --git a/package-lock.json b/package-lock.json index ec8fd0c..e08ee50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sofarr", - "version": "1.7.24", + "version": "1.7.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sofarr", - "version": "1.7.24", + "version": "1.7.25", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/package.json b/package.json index 9db16fc..c47ad08 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.7.24", + "version": "1.7.25", "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 b48c922..22637fc 100644 --- a/server/app.js +++ b/server/app.js @@ -132,7 +132,7 @@ function createApp({ skipRateLimits = false } = {}) { * version: * type: string * description: sofarr version - * example: "1.7.24" + * example: "1.7.25" * x-code-samples: * - lang: curl * label: cURL diff --git a/server/clients/OmbiRetriever.js b/server/clients/OmbiRetriever.js index 067afd4..b138674 100644 --- a/server/clients/OmbiRetriever.js +++ b/server/clients/OmbiRetriever.js @@ -163,12 +163,14 @@ class OmbiRetriever extends ArrRetriever { _hydrateRequest(req) { if (!req) return req; + let result = req; + const reqUserId = req.requestedUserId || req.RequestedUserId; if (reqUserId && this.cache.userMap.has(reqUserId)) { const cachedUser = this.cache.userMap.get(reqUserId); - + let requestedUser = req.requestedUser || req.RequestedUser; - + // If requestedUser is not an object or is empty/null, populate it if (!requestedUser || typeof requestedUser !== 'object' || Object.keys(requestedUser).length === 0) { const hydratedUser = { @@ -178,15 +180,56 @@ class OmbiRetriever extends ArrRetriever { userAlias: cachedUser.userAlias || cachedUser.UserAlias || '', normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || '' }; - - return { + + result = { ...req, requestedUser: hydratedUser, RequestedUser: hydratedUser }; } } - return req; + + // Hydrate childRequests (common for Ombi TV show requests) + if (Array.isArray(result.childRequests) && result.childRequests.length > 0) { + const hydratedChildren = result.childRequests.map(child => { + if (!child) return child; + + const childUserId = child.requestedUserId || child.RequestedUserId; + if (childUserId && this.cache.userMap.has(childUserId)) { + const cachedUser = this.cache.userMap.get(childUserId); + let childUser = child.requestedUser || child.RequestedUser; + + if (!childUser || typeof childUser !== 'object' || Object.keys(childUser).length === 0) { + const hydratedUser = { + id: cachedUser.id, + userName: cachedUser.userName, + alias: cachedUser.alias || cachedUser.Alias || '', + userAlias: cachedUser.userAlias || cachedUser.UserAlias || '', + normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || '' + }; + + return { + ...child, + requestedUser: hydratedUser, + RequestedUser: hydratedUser + }; + } + } + return child; + }); + + result = { ...result, childRequests: hydratedChildren }; + } + + // Promote requestedDate from childRequests to top level (common for Ombi TV) + if (!result.requestedDate && Array.isArray(result.childRequests) && result.childRequests.length > 0) { + const childDate = result.childRequests[0].requestedDate || result.childRequests[0].RequestedDate; + if (childDate) { + result = { ...result, requestedDate: childDate }; + } + } + + return result; } /** diff --git a/server/index.js b/server/index.js index 22e833c..4e3af21 100644 --- a/server/index.js +++ b/server/index.js @@ -249,7 +249,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads * version: * type: string * description: sofarr version - * example: "1.7.24" + * example: "1.7.25" */ app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime(), version }); diff --git a/server/utils/ombiFilters.js b/server/utils/ombiFilters.js index f6e0371..6d1548d 100644 --- a/server/utils/ombiFilters.js +++ b/server/utils/ombiFilters.js @@ -17,6 +17,23 @@ function getRequestStatus(request) { if (request.denied) return 'denied'; if (request.approved) return 'approved'; if (request.requested) return 'pending'; + + // Ombi TV requests store status flags inside childRequests + if (Array.isArray(request.childRequests) && request.childRequests.length > 0) { + for (const child of request.childRequests) { + if (child && child.available) return 'available'; + } + for (const child of request.childRequests) { + if (child && child.denied) return 'denied'; + } + for (const child of request.childRequests) { + if (child && child.approved) return 'approved'; + } + for (const child of request.childRequests) { + if (child && child.requested) return 'pending'; + } + } + return 'unknown'; } diff --git a/tests/unit/arrRetrievers.test.js b/tests/unit/arrRetrievers.test.js index d2e4dcb..662f8aa 100644 --- a/tests/unit/arrRetrievers.test.js +++ b/tests/unit/arrRetrievers.test.js @@ -183,3 +183,152 @@ describe('arrRetrieverRegistry', () => { }); }); }); + +describe('OmbiRetriever._hydrateRequest', () => { + let retriever; + + beforeEach(() => { + retriever = new OmbiRetriever({ + id: 'ombi-test', + name: 'Test Ombi', + url: 'http://localhost:5000', + apiKey: 'test-key' + }); + + // Seed the userMap cache + retriever.cache.userMap.set('user-1', { + id: 'user-1', + userName: 'testuser', + alias: 'TestUser', + userAlias: 'TestUser', + normalizedUserName: 'testuser' + }); + retriever.cache.userMap.set('user-2', { + id: 'user-2', + userName: 'adminuser', + alias: 'AdminUser', + userAlias: 'AdminUser', + normalizedUserName: 'adminuser' + }); + }); + + it('hydrates top-level requestedUserId', () => { + const req = { + id: 1, + requestedUserId: 'user-1', + requestedUser: {} + }; + const result = retriever._hydrateRequest(req); + expect(result.requestedUser.userName).toBe('testuser'); + expect(result.requestedUser.alias).toBe('TestUser'); + }); + + it('hydrates childRequests requestedUserId (TV requests)', () => { + const req = { + id: 3, + title: 'Test Show', + requestedUserId: 'user-1', + requestedUser: {}, + childRequests: [ + { + id: 10, + requestedUserId: 'user-2', + requestedUser: {} + } + ] + }; + const result = retriever._hydrateRequest(req); + expect(result.requestedUser.userName).toBe('testuser'); + expect(result.childRequests[0].requestedUser.userName).toBe('adminuser'); + expect(result.childRequests[0].requestedUser.alias).toBe('AdminUser'); + }); + + it('promotes requestedDate from childRequests to top level', () => { + const req = { + id: 3, + title: 'Test Show', + childRequests: [ + { + id: 10, + requestedDate: '2026-05-15T10:00:00.000Z' + } + ] + }; + const result = retriever._hydrateRequest(req); + expect(result.requestedDate).toBe('2026-05-15T10:00:00.000Z'); + expect(result.childRequests[0].requestedDate).toBe('2026-05-15T10:00:00.000Z'); + }); + + it('does not overwrite existing top-level requestedDate', () => { + const req = { + id: 3, + requestedDate: '2026-01-01T00:00:00.000Z', + childRequests: [ + { + id: 10, + requestedDate: '2026-05-15T10:00:00.000Z' + } + ] + }; + const result = retriever._hydrateRequest(req); + expect(result.requestedDate).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('handles PascalCase RequestedDate from childRequests', () => { + const req = { + id: 3, + childRequests: [ + { + id: 10, + RequestedDate: '2026-06-01T12:00:00.000Z' + } + ] + }; + const result = retriever._hydrateRequest(req); + expect(result.requestedDate).toBe('2026-06-01T12:00:00.000Z'); + }); + + it('returns unmodified request when no hydration needed', () => { + const req = { + id: 1, + title: 'Test Movie', + requestedUser: { userName: 'existing', alias: 'Existing' } + }; + const result = retriever._hydrateRequest(req); + expect(result).toEqual(req); + }); + + it('handles null childRequests gracefully', () => { + const req = { + id: 3, + childRequests: null + }; + const result = retriever._hydrateRequest(req); + expect(result).toEqual(req); + }); + + it('handles empty childRequests gracefully', () => { + const req = { + id: 3, + childRequests: [] + }; + const result = retriever._hydrateRequest(req); + expect(result).toEqual(req); + }); + + it('skips child hydration when child already has valid requestedUser', () => { + const req = { + id: 3, + childRequests: [ + { + id: 10, + requestedUserId: 'user-1', + requestedUser: { userName: 'already_set', alias: 'AlreadySet' } + } + ] + }; + const result = retriever._hydrateRequest(req); + expect(result.childRequests[0].requestedUser.userName).toBe('already_set'); + expect(result.childRequests[0].requestedUser.alias).toBe('AlreadySet'); + }); +}); diff --git a/tests/unit/ombiFilters.test.js b/tests/unit/ombiFilters.test.js index 481ac42..397a17e 100644 --- a/tests/unit/ombiFilters.test.js +++ b/tests/unit/ombiFilters.test.js @@ -58,6 +58,40 @@ describe('getRequestStatus', () => { expect(getRequestStatus(makeRequest({ denied: true, approved: true }))).toBe('denied'); expect(getRequestStatus(makeRequest({ approved: true, requested: true }))).toBe('approved'); }); + + it('returns available from childRequests when top-level is absent (TV)', () => { + expect(getRequestStatus({ childRequests: [{ available: true }] })).toBe('available'); + }); + + it('returns denied from childRequests when top-level is absent (TV)', () => { + expect(getRequestStatus({ childRequests: [{ denied: true }] })).toBe('denied'); + }); + + it('returns approved from childRequests when top-level is absent (TV)', () => { + expect(getRequestStatus({ childRequests: [{ approved: true }] })).toBe('approved'); + }); + + it('returns pending from childRequests when top-level is absent (TV)', () => { + expect(getRequestStatus({ childRequests: [{ requested: true }] })).toBe('pending'); + }); + + it('follows priority inside childRequests: available > denied > approved > pending', () => { + expect(getRequestStatus({ childRequests: [ + { available: true, denied: true }, + { approved: true } + ]})).toBe('available'); + expect(getRequestStatus({ childRequests: [ + { denied: true, approved: true }, + { requested: true } + ]})).toBe('denied'); + expect(getRequestStatus({ childRequests: [ + { approved: true, requested: true } + ]})).toBe('approved'); + }); + + it('returns unknown for TV request with empty childRequests', () => { + expect(getRequestStatus({ childRequests: [] })).toBe('unknown'); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/ombiHelpers.test.js b/tests/unit/ombiHelpers.test.js index bdf5ed8..c205577 100644 --- a/tests/unit/ombiHelpers.test.js +++ b/tests/unit/ombiHelpers.test.js @@ -130,6 +130,34 @@ describe('ombiHelpers', () => { }; expect(extractRequestedUser(req)).toBe('child_user'); }); + + it('recursively extracts user from childRequests requestedUser object (hydrated TV)', () => { + const req = { + childRequests: [ + {}, + { requestedUser: { userName: 'tv_user', alias: 'tv_alias' } } + ] + }; + expect(extractRequestedUser(req)).toBe('tv_alias'); + }); + + it('recursively extracts user from childRequests requestedUser as string', () => { + const req = { + childRequests: [ + { requestedUser: 'string_user' } + ] + }; + expect(extractRequestedUser(req)).toBe('string_user'); + }); + + it('extracts user from deeply nested childRequests with requestedByAlias fallback', () => { + const req = { + childRequests: [ + { requestedByAlias: 'deep_alias' } + ] + }; + expect(extractRequestedUser(req)).toBe('deep_alias'); + }); }); describe('filterRequestsByUser', () => {