fix(ombi): resolve TV request status, user, and date display (Issue #53)
Build and Push Docker Image / build (push) Successful in 1m46s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m33s
CI / Security audit (push) Successful in 1m56s
CI / Swagger Validation & Coverage (push) Successful in 2m35s
CI / Tests & coverage (push) Successful in 2m51s
Build and Push Docker Image / build (push) Successful in 1m46s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m33s
CI / Security audit (push) Successful in 1m56s
CI / Swagger Validation & Coverage (push) Successful in 2m35s
CI / Tests & coverage (push) Successful in 2m51s
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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 });
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user