Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f6aa5b967 | |||
| 5390bbf615 |
@@ -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/).
|
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).
|
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
|
## [1.7.19] - 2026-05-25
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.19",
|
"version": "1.7.20",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.19",
|
"version": "1.7.20",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"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",
|
"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",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-1
@@ -132,7 +132,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.7.19"
|
* example: "1.7.20"
|
||||||
* x-code-samples:
|
* x-code-samples:
|
||||||
* - lang: curl
|
* - lang: curl
|
||||||
* label: cURL
|
* label: cURL
|
||||||
|
|||||||
@@ -125,6 +125,20 @@ class OmbiClient {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all users from Ombi
|
||||||
|
* @returns {Promise<Array>} 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;
|
module.exports = OmbiClient;
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
this.cache = {
|
this.cache = {
|
||||||
movieRequests: [],
|
movieRequests: [],
|
||||||
tvRequests: [],
|
tvRequests: [],
|
||||||
|
users: [],
|
||||||
movieMap: new Map(), // tmdbId -> request
|
movieMap: new Map(), // tmdbId -> request
|
||||||
tvMap: new Map(), // tvdbId -> request
|
tvMap: new Map(), // tvdbId -> request
|
||||||
|
userMap: new Map(), // id -> user
|
||||||
lastFetch: 0,
|
lastFetch: 0,
|
||||||
ttl: 5 * 60 * 1000 // 5 minutes TTL
|
ttl: 5 * 60 * 1000 // 5 minutes TTL
|
||||||
};
|
};
|
||||||
@@ -98,20 +100,32 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
try {
|
try {
|
||||||
logToFile('[OmbiRetriever] Refreshing cache');
|
logToFile('[OmbiRetriever] Refreshing cache');
|
||||||
|
|
||||||
// Fetch requests in parallel
|
// Fetch requests and users in parallel
|
||||||
const [movieRequests, tvRequests] = await Promise.all([
|
const [movieRequests, tvRequests, users] = await Promise.all([
|
||||||
this.client.getMovieRequests(),
|
this.client.getMovieRequests(),
|
||||||
this.client.getTvRequests()
|
this.client.getTvRequests(),
|
||||||
|
this.client.getUsers()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
this.cache.movieRequests = movieRequests;
|
this.cache.movieRequests = movieRequests;
|
||||||
this.cache.tvRequests = tvRequests;
|
this.cache.tvRequests = tvRequests;
|
||||||
|
this.cache.users = users;
|
||||||
this.cache.lastFetch = Date.now();
|
this.cache.lastFetch = Date.now();
|
||||||
|
|
||||||
// Build lookup maps
|
// Build lookup maps
|
||||||
this.cache.movieMap.clear();
|
this.cache.movieMap.clear();
|
||||||
this.cache.tvMap.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)
|
// Build movie map (tmdbId -> request)
|
||||||
movieRequests.forEach(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) {
|
} catch (error) {
|
||||||
logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`);
|
logToFile(`[OmbiRetriever] Cache refresh failed: ${error.message}`);
|
||||||
// Don't throw error, continue with stale cache if available
|
// 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
|
* Get all movie requests
|
||||||
* @param {boolean} force - Whether to force refresh from API
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
@@ -147,7 +207,7 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
*/
|
*/
|
||||||
async getMovieRequests(force = false) {
|
async getMovieRequests(force = false) {
|
||||||
await this.refreshCache(force);
|
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) {
|
async getTvRequests(force = false) {
|
||||||
await this.refreshCache(force);
|
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
|
// Try TMDB ID first
|
||||||
if (tmdbId && this.cache.movieMap.has(tmdbId)) {
|
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
|
// Try IMDB ID as fallback
|
||||||
if (imdbId && this.cache.movieMap.has(imdbId)) {
|
if (imdbId && this.cache.movieMap.has(imdbId)) {
|
||||||
return this.cache.movieMap.get(imdbId);
|
return this._hydrateRequest(this.cache.movieMap.get(imdbId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -193,12 +253,12 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
|
|
||||||
// Try TVDB ID first
|
// Try TVDB ID first
|
||||||
if (tvdbId && this.cache.tvMap.has(tvdbId)) {
|
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
|
// Try TMDB ID as fallback
|
||||||
if (tmdbId && this.cache.tvMap.has(tmdbId)) {
|
if (tmdbId && this.cache.tvMap.has(tmdbId)) {
|
||||||
return this.cache.tvMap.get(tmdbId);
|
return this._hydrateRequest(this.cache.tvMap.get(tmdbId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+1
-1
@@ -249,7 +249,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.7.19"
|
* example: "1.7.20"
|
||||||
*/
|
*/
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ info:
|
|||||||
|
|
||||||
## SSE Streaming
|
## SSE Streaming
|
||||||
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
||||||
version: 1.7.19
|
version: 1.7.20
|
||||||
contact:
|
contact:
|
||||||
name: sofarr
|
name: sofarr
|
||||||
license:
|
license:
|
||||||
|
|||||||
@@ -336,4 +336,47 @@ describe('OmbiClient', () => {
|
|||||||
expect(result).toBe(false);
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -766,4 +766,70 @@ describe('OmbiRetriever', () => {
|
|||||||
expect(stats.age).toBeGreaterThanOrEqual(0);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user