diff --git a/CHANGELOG.md b/CHANGELOG.md index fd0472c..b2a71f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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.20] - 2026-05-26 + +### Fixed + +- **Ombi Requesting User Hydration (Issue #51)** — Resolved a bug where Sofarr displayed "Unknown" for the requesting user on requests even when the Ombi database contains valid user information. Added automatic requestedUser object hydration on the server side by fetching the full user list from `/api/v1/Identity/Users` and caching it in memory. If a request is missing the nested `requestedUser` details but possesses a valid `requestedUserId`, Sofarr automatically resolves and binds the user's username/alias. Added robust unit tests safeguarding the client and the retriever. Resolves Gitea Issue [#51](https://git.i3omb.com/Gandalf/sofarr/issues/51). + +### Changed + +- **Aligned Gitea Interaction Skill** — Updated `.windsurf/skills/gitea-interaction/SKILL.md` to align with the Antigravity `tea-interaction` hybrid interaction model guidelines, detailing when to use the editor extension versus the Gitea CLI. + +--- + ## [1.7.19] - 2026-05-25 ### Fixed diff --git a/package-lock.json b/package-lock.json index 1238653..8d44879 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sofarr", - "version": "1.7.19", + "version": "1.7.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sofarr", - "version": "1.7.19", + "version": "1.7.20", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/package.json b/package.json index ca77866..56b7d00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.7.19", + "version": "1.7.20", "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 c30ef34..ac96058 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.19" + * example: "1.7.20" * x-code-samples: * - lang: curl * label: cURL diff --git a/server/clients/OmbiClient.js b/server/clients/OmbiClient.js index 67a009e..3b9575f 100644 --- a/server/clients/OmbiClient.js +++ b/server/clients/OmbiClient.js @@ -125,6 +125,20 @@ class OmbiClient { return false; } } + + /** + * Get all users from Ombi + * @returns {Promise} Array of user objects + */ + async getUsers() { + try { + const response = await this.axios.get(`${this.url}/api/v1/Identity/Users`); + return response.data || []; + } catch (error) { + logToFile(`[OmbiClient] Get users error: ${error.message}`); + return []; + } + } } module.exports = OmbiClient; diff --git a/server/clients/OmbiRetriever.js b/server/clients/OmbiRetriever.js index 9ae5ba7..067afd4 100644 --- a/server/clients/OmbiRetriever.js +++ b/server/clients/OmbiRetriever.js @@ -16,8 +16,10 @@ class OmbiRetriever extends ArrRetriever { this.cache = { movieRequests: [], tvRequests: [], + users: [], movieMap: new Map(), // tmdbId -> request tvMap: new Map(), // tvdbId -> request + userMap: new Map(), // id -> user lastFetch: 0, ttl: 5 * 60 * 1000 // 5 minutes TTL }; @@ -98,20 +100,32 @@ class OmbiRetriever extends ArrRetriever { try { logToFile('[OmbiRetriever] Refreshing cache'); - // Fetch requests in parallel - const [movieRequests, tvRequests] = await Promise.all([ + // Fetch requests and users in parallel + const [movieRequests, tvRequests, users] = await Promise.all([ this.client.getMovieRequests(), - this.client.getTvRequests() + this.client.getTvRequests(), + this.client.getUsers() ]); // Update cache this.cache.movieRequests = movieRequests; this.cache.tvRequests = tvRequests; + this.cache.users = users; this.cache.lastFetch = Date.now(); // Build lookup maps this.cache.movieMap.clear(); this.cache.tvMap.clear(); + this.cache.userMap.clear(); + + // Build user map (id -> user) + if (Array.isArray(users)) { + users.forEach(user => { + if (user && user.id) { + this.cache.userMap.set(user.id, user); + } + }); + } // Build movie map (tmdbId -> request) movieRequests.forEach(request => { @@ -133,13 +147,59 @@ class OmbiRetriever extends ArrRetriever { } }); - logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows`); + logToFile(`[OmbiRetriever] Cache refreshed: ${movieRequests.length} movies, ${tvRequests.length} TV shows, ${users.length} users`); } catch (error) { logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`); // Don't throw error, continue with stale cache if available } } + /** + * Hydrates requestedUser on a single request using the userMap cache + * @param {Object} req - The request object + * @returns {Object} Hydrated request object + * @private + */ + _hydrateRequest(req) { + if (!req) return 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 = { + id: cachedUser.id, + userName: cachedUser.userName, + alias: cachedUser.alias || cachedUser.Alias || '', + userAlias: cachedUser.userAlias || cachedUser.UserAlias || '', + normalizedUserName: cachedUser.normalizedUserName || cachedUser.NormalizedUserName || '' + }; + + return { + ...req, + requestedUser: hydratedUser, + RequestedUser: hydratedUser + }; + } + } + return req; + } + + /** + * Hydrates requestedUser on a list of requests using the userMap cache + * @param {Array} requests - Array of request objects + * @returns {Array} Array of hydrated request objects + * @private + */ + _hydrateRequests(requests) { + if (!Array.isArray(requests)) return []; + return requests.map(req => this._hydrateRequest(req)); + } + /** * Get all movie requests * @param {boolean} force - Whether to force refresh from API @@ -147,7 +207,7 @@ class OmbiRetriever extends ArrRetriever { */ async getMovieRequests(force = false) { await this.refreshCache(force); - return this.cache.movieRequests; + return this._hydrateRequests(this.cache.movieRequests); } /** @@ -157,7 +217,7 @@ class OmbiRetriever extends ArrRetriever { */ async getTvRequests(force = false) { await this.refreshCache(force); - return this.cache.tvRequests; + return this._hydrateRequests(this.cache.tvRequests); } /** @@ -171,12 +231,12 @@ class OmbiRetriever extends ArrRetriever { // Try TMDB ID first if (tmdbId && this.cache.movieMap.has(tmdbId)) { - return this.cache.movieMap.get(tmdbId); + return this._hydrateRequest(this.cache.movieMap.get(tmdbId)); } // Try IMDB ID as fallback if (imdbId && this.cache.movieMap.has(imdbId)) { - return this.cache.movieMap.get(imdbId); + return this._hydrateRequest(this.cache.movieMap.get(imdbId)); } return null; @@ -193,12 +253,12 @@ class OmbiRetriever extends ArrRetriever { // Try TVDB ID first if (tvdbId && this.cache.tvMap.has(tvdbId)) { - return this.cache.tvMap.get(tvdbId); + return this._hydrateRequest(this.cache.tvMap.get(tvdbId)); } // Try TMDB ID as fallback if (tmdbId && this.cache.tvMap.has(tmdbId)) { - return this.cache.tvMap.get(tmdbId); + return this._hydrateRequest(this.cache.tvMap.get(tmdbId)); } return null; diff --git a/server/index.js b/server/index.js index cc77b1b..2126748 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.19" + * example: "1.7.20" */ app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime(), version }); diff --git a/server/openapi.yaml b/server/openapi.yaml index ae7a35a..8e5489f 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -22,7 +22,7 @@ info: ## SSE Streaming Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream. - version: 1.7.19 + version: 1.7.20 contact: name: sofarr license: diff --git a/tests/unit/clients/OmbiClient.test.js b/tests/unit/clients/OmbiClient.test.js index f9bbe14..00020d1 100644 --- a/tests/unit/clients/OmbiClient.test.js +++ b/tests/unit/clients/OmbiClient.test.js @@ -336,4 +336,47 @@ describe('OmbiClient', () => { expect(result).toBe(false); }); }); + + describe('getUsers', () => { + it('should return user array for successful request', async () => { + const mockUsers = [ + { id: '1', userName: 'Gordon' }, + { id: '2', userName: 'Alice' } + ]; + + nock(baseUrl) + .get('/api/v1/Identity/Users') + .matchHeader('ApiKey', apiKey) + .reply(200, mockUsers); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.getUsers(); + + expect(result).toEqual(mockUsers); + }); + + it('should return empty array on API error', async () => { + nock(baseUrl) + .get('/api/v1/Identity/Users') + .matchHeader('ApiKey', apiKey) + .reply(500, { error: 'Internal Server Error' }); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.getUsers(); + + expect(result).toEqual([]); + }); + + it('should return empty array on network error', async () => { + nock(baseUrl) + .get('/api/v1/Identity/Users') + .matchHeader('ApiKey', apiKey) + .replyWithError('Network error'); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.getUsers(); + + expect(result).toEqual([]); + }); + }); }); diff --git a/tests/unit/clients/OmbiRetriever.test.js b/tests/unit/clients/OmbiRetriever.test.js index 80407c0..880f52b 100644 --- a/tests/unit/clients/OmbiRetriever.test.js +++ b/tests/unit/clients/OmbiRetriever.test.js @@ -766,4 +766,70 @@ describe('OmbiRetriever', () => { expect(stats.age).toBeGreaterThanOrEqual(0); }); }); + + describe('hydration logic', () => { + it('should hydrate requestedUser when missing but requestedUserId is present', async () => { + const mockMovies = [ + { id: 1, title: 'Movie 1', requestedUserId: 'gordon-id', requestedUser: null } + ]; + const mockTvShows = []; + const mockUsers = [ + { id: 'gordon-id', userName: 'Gordon', alias: 'G-Man' } + ]; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + nock(baseUrl) + .get('/api/v1/Identity/Users') + .reply(200, mockUsers); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.getMovieRequests(); + + expect(result).toHaveLength(1); + expect(result[0].requestedUser).toBeDefined(); + expect(result[0].requestedUser.userName).toBe('Gordon'); + expect(result[0].requestedUser.alias).toBe('G-Man'); + }); + + it('should not overwrite non-empty requestedUser object', async () => { + const mockMovies = [ + { + id: 1, + title: 'Movie 1', + requestedUserId: 'gordon-id', + requestedUser: { userName: 'ExistingGordon', alias: 'ExistingG' } + } + ]; + const mockTvShows = []; + const mockUsers = [ + { id: 'gordon-id', userName: 'Gordon', alias: 'G-Man' } + ]; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + nock(baseUrl) + .get('/api/v1/Identity/Users') + .reply(200, mockUsers); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.getMovieRequests(); + + expect(result).toHaveLength(1); + expect(result[0].requestedUser.userName).toBe('ExistingGordon'); + expect(result[0].requestedUser.alias).toBe('ExistingG'); + }); + }); });