feat: Add Ombi request filtering and search
Build and Push Docker Image / build (push) Successful in 1m29s
Docs Check / Markdown lint (push) Successful in 1m51s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m3s
CI / Security audit (push) Successful in 2m54s
CI / Swagger Validation & Coverage (push) Successful in 3m6s
Docs Check / Mermaid diagram parse check (push) Successful in 3m13s
CI / Tests & coverage (push) Successful in 3m31s
Build and Push Docker Image / build (push) Successful in 1m29s
Docs Check / Markdown lint (push) Successful in 1m51s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m3s
CI / Security audit (push) Successful in 2m54s
CI / Swagger Validation & Coverage (push) Successful in 3m6s
Docs Check / Mermaid diagram parse check (push) Successful in 3m13s
CI / Tests & coverage (push) Successful in 3m31s
- Add request filters UI (type, status, sort, search) - Implement dual-layer filtering (server + client) - Add ombiFilters utility for consistent filtering logic - Persist filter preferences in localStorage - Add SSE support for real-time Ombi request updates - Add webhook endpoints for Ombi integration - Update OpenAPI spec for new endpoints - Add unit tests for filter logic and UI - Add integration tests for Ombi routes
This commit is contained in:
@@ -218,6 +218,58 @@ components:
|
||||
description: Additional error details (dev-only)
|
||||
example: "Emby API returned 401"
|
||||
|
||||
OmbiRequest:
|
||||
type: object
|
||||
description: Normalised Ombi movie or TV request
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
example: 123
|
||||
title:
|
||||
type: string
|
||||
example: "The Batman"
|
||||
requestedDate:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
example: "2026-05-21T10:00:00.000Z"
|
||||
available:
|
||||
type: boolean
|
||||
example: false
|
||||
approved:
|
||||
type: boolean
|
||||
example: true
|
||||
denied:
|
||||
type: boolean
|
||||
example: false
|
||||
requested:
|
||||
type: boolean
|
||||
example: true
|
||||
deniedReason:
|
||||
type: string
|
||||
nullable: true
|
||||
example: "Already on Plex"
|
||||
theMovieDbId:
|
||||
type: integer
|
||||
nullable: true
|
||||
example: 414906
|
||||
year:
|
||||
type: integer
|
||||
nullable: true
|
||||
example: 2022
|
||||
quality:
|
||||
type: string
|
||||
nullable: true
|
||||
example: "1080p"
|
||||
requestedUser:
|
||||
type: object
|
||||
nullable: true
|
||||
mediaType:
|
||||
type: string
|
||||
enum: [movie, tv]
|
||||
description: Injected by Sofarr to distinguish movies from TV
|
||||
example: "movie"
|
||||
|
||||
BlocklistSearchRequest:
|
||||
type: object
|
||||
required:
|
||||
|
||||
+85
-15
@@ -5,6 +5,7 @@ const cache = require('../utils/cache');
|
||||
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const { extractRequestedUser } = require('../utils/ombiHelpers');
|
||||
const { applyRequestFilters } = require('../utils/ombiFilters');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -18,9 +19,52 @@ const router = express.Router();
|
||||
* Returns Ombi movie and TV requests. Non-admin users only see their own requests
|
||||
* (filtered by Emby user mapping), while admins see all requests.
|
||||
*
|
||||
* Supports server-side filtering by media type, request status, title search,
|
||||
* and sorting by requested date or title.
|
||||
*
|
||||
* **Authentication:** Requires cookie authentication.
|
||||
* security:
|
||||
* - cookieAuth: []
|
||||
* parameters:
|
||||
* - name: type
|
||||
* in: query
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* enum: [movie, tv, all]
|
||||
* default: [all]
|
||||
* description: Filter by media type. Omit or use `all` for both.
|
||||
* style: form
|
||||
* explode: true
|
||||
* - name: status
|
||||
* in: query
|
||||
* schema:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* enum: [pending, approved, available, denied]
|
||||
* description: Filter by request status. Omit for all statuses.
|
||||
* style: form
|
||||
* explode: true
|
||||
* - name: sort
|
||||
* in: query
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [requestedDate_desc, requestedDate_asc, title_asc, title_desc]
|
||||
* default: requestedDate_desc
|
||||
* description: Sort mode.
|
||||
* - name: search
|
||||
* in: query
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Case-insensitive substring match on title.
|
||||
* - name: showAll
|
||||
* in: query
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: ['true', 'false']
|
||||
* description: Admin only. Show all users' requests.
|
||||
* responses:
|
||||
* '200':
|
||||
* description: Ombi requests retrieved successfully
|
||||
@@ -35,17 +79,20 @@ const router = express.Router();
|
||||
* isAdmin:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* showAll:
|
||||
* type: boolean
|
||||
* example: false
|
||||
* requests:
|
||||
* type: object
|
||||
* properties:
|
||||
* movie:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* $ref: '#/components/schemas/OmbiRequest'
|
||||
* tv:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* $ref: '#/components/schemas/OmbiRequest'
|
||||
* total:
|
||||
* type: integer
|
||||
* example: 5
|
||||
@@ -59,11 +106,12 @@ const router = express.Router();
|
||||
router.get('/requests', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = req.user;
|
||||
const isAdmin = req.isAdmin;
|
||||
const isAdmin = user.isAdmin;
|
||||
const username = user.name;
|
||||
const showAll = isAdmin && req.query.showAll === 'true';
|
||||
|
||||
const arrRetrieverRegistry = require('../utils/arrRetrieverRegistry');
|
||||
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||
// initialize() is idempotent - cheap no-op if already initialized
|
||||
await arrRetrieverRegistry.initialize();
|
||||
|
||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
|
||||
@@ -73,31 +121,51 @@ router.get('/requests', requireAuth, async (req, res) => {
|
||||
let filteredTvRequests = ombiRequests.tv || [];
|
||||
|
||||
if (!showAll && username) {
|
||||
// Ombi uses requestedUser field to track who made the request
|
||||
// Match by username (case-insensitive)
|
||||
const usernameLower = username.toLowerCase();
|
||||
|
||||
filteredMovieRequests = filteredMovieRequests.filter(req => {
|
||||
const requestedUser = extractRequestedUser(req);
|
||||
// Filter to show only the current user's requests.
|
||||
// Requests with no identifiable user (empty string) are hidden from non-admins.
|
||||
filteredMovieRequests = filteredMovieRequests.filter(reqItem => {
|
||||
const requestedUser = extractRequestedUser(reqItem);
|
||||
return requestedUser.toLowerCase() === usernameLower;
|
||||
});
|
||||
|
||||
filteredTvRequests = filteredTvRequests.filter(req => {
|
||||
const requestedUser = extractRequestedUser(req);
|
||||
filteredTvRequests = filteredTvRequests.filter(reqItem => {
|
||||
const requestedUser = extractRequestedUser(reqItem);
|
||||
return requestedUser.toLowerCase() === usernameLower;
|
||||
});
|
||||
}
|
||||
|
||||
const total = filteredMovieRequests.length + filteredTvRequests.length;
|
||||
// Tag with mediaType and flatten for filtering/sorting
|
||||
const allRequests = [
|
||||
...filteredMovieRequests.map(r => ({ ...r, mediaType: 'movie' })),
|
||||
...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' }))
|
||||
];
|
||||
|
||||
// Parse query params
|
||||
let types = req.query.type;
|
||||
let statuses = req.query.status;
|
||||
const sort = req.query.sort || 'requestedDate_desc';
|
||||
const search = req.query.search || '';
|
||||
|
||||
// Normalise to arrays
|
||||
if (typeof types === 'string') types = [types];
|
||||
if (typeof statuses === 'string') statuses = [statuses];
|
||||
|
||||
// Apply filters and sorting
|
||||
const filtered = applyRequestFilters(allRequests, { types, statuses, sort, search });
|
||||
|
||||
// Split back into movie/tv
|
||||
const movie = filtered.filter(r => r.mediaType === 'movie');
|
||||
const tv = filtered.filter(r => r.mediaType === 'tv');
|
||||
|
||||
const total = filtered.length;
|
||||
|
||||
res.json({
|
||||
user: username,
|
||||
isAdmin,
|
||||
showAll,
|
||||
requests: {
|
||||
movie: filteredMovieRequests,
|
||||
tv: filteredTvRequests
|
||||
},
|
||||
requests: { movie, tv },
|
||||
total
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -330,6 +398,8 @@ router.get('/webhook/status', requireAuth, async (req, res) => {
|
||||
enabled: webhookConfig.enabled || false,
|
||||
webhookUrl: webhookConfig.webhookUrl || null,
|
||||
applicationToken: webhookConfig.applicationToken || null,
|
||||
// Note: Ombi may support per-trigger toggles, but we currently treat
|
||||
// them as all-on or all-off based on webhookConfig.enabled
|
||||
triggers: {
|
||||
requestAvailable: webhookConfig.enabled || false,
|
||||
requestApproved: webhookConfig.enabled || false,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
/**
|
||||
* Pure filter / sort / search utilities for Ombi requests.
|
||||
* Must stay in sync with client/src/utils/ombiFilters.js
|
||||
*/
|
||||
|
||||
/**
|
||||
* Derive a single status string from an Ombi request object.
|
||||
* Priority: available > denied > approved > pending > unknown
|
||||
*
|
||||
* @param {Object} request
|
||||
* @returns {string} 'available' | 'denied' | 'approved' | 'pending' | 'unknown'
|
||||
*/
|
||||
function getRequestStatus(request) {
|
||||
if (!request) return 'unknown';
|
||||
if (request.available) return 'available';
|
||||
if (request.denied) return 'denied';
|
||||
if (request.approved) return 'approved';
|
||||
if (request.requested) return 'pending';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter requests by media type.
|
||||
*
|
||||
* @param {Array} requests
|
||||
* @param {string[]} types - e.g. ['movie', 'tv'] or ['all']
|
||||
* @returns {Array}
|
||||
*/
|
||||
function filterByType(requests, types) {
|
||||
if (!types || types.length === 0) return requests;
|
||||
const normalized = types.map(t => t.toLowerCase());
|
||||
if (normalized.includes('all')) return requests;
|
||||
return requests.filter(r => normalized.includes(r.mediaType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter requests by status.
|
||||
*
|
||||
* @param {Array} requests
|
||||
* @param {string[]} statuses - e.g. ['pending', 'approved', 'available', 'denied']
|
||||
* @returns {Array}
|
||||
*/
|
||||
function filterByStatus(requests, statuses) {
|
||||
if (!statuses || statuses.length === 0) return requests;
|
||||
const normalized = statuses.map(s => s.toLowerCase());
|
||||
return requests.filter(r => normalized.includes(getRequestStatus(r)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter requests by case-insensitive title substring.
|
||||
*
|
||||
* @param {Array} requests
|
||||
* @param {string} query
|
||||
* @returns {Array}
|
||||
*/
|
||||
function filterBySearch(requests, query) {
|
||||
if (!query || query.trim() === '') return requests;
|
||||
const q = query.trim().toLowerCase();
|
||||
return requests.filter(r => (r.title || '').toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort requests by the given sort mode.
|
||||
*
|
||||
* @param {Array} requests
|
||||
* @param {string} sortMode - requestedDate_desc | requestedDate_asc | title_asc | title_desc
|
||||
* @returns {Array} new sorted array
|
||||
*/
|
||||
function sortRequests(requests, sortMode) {
|
||||
const sorted = [...requests];
|
||||
switch (sortMode) {
|
||||
case 'requestedDate_asc':
|
||||
return sorted.sort((a, b) => {
|
||||
const da = a.requestedDate ? new Date(a.requestedDate).getTime() : 0;
|
||||
const db = b.requestedDate ? new Date(b.requestedDate).getTime() : 0;
|
||||
return da - db;
|
||||
});
|
||||
case 'title_asc':
|
||||
return sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
||||
case 'title_desc':
|
||||
return sorted.sort((a, b) => (b.title || '').localeCompare(a.title || ''));
|
||||
case 'requestedDate_desc':
|
||||
default:
|
||||
return sorted.sort((a, b) => {
|
||||
const da = a.requestedDate ? new Date(a.requestedDate).getTime() : 0;
|
||||
const db = b.requestedDate ? new Date(b.requestedDate).getTime() : 0;
|
||||
return db - da;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all filters and sorting in one call.
|
||||
*
|
||||
* @param {Array} requests
|
||||
* @param {Object} options
|
||||
* @param {string[]} options.types
|
||||
* @param {string[]} options.statuses
|
||||
* @param {string} options.sort
|
||||
* @param {string} options.search
|
||||
* @returns {Array}
|
||||
*/
|
||||
function applyRequestFilters(requests, { types, statuses, sort, search } = {}) {
|
||||
let result = [...requests];
|
||||
result = filterByType(result, types);
|
||||
result = filterByStatus(result, statuses);
|
||||
result = filterBySearch(result, search);
|
||||
result = sortRequests(result, sort);
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRequestStatus,
|
||||
filterByType,
|
||||
filterByStatus,
|
||||
filterBySearch,
|
||||
sortRequests,
|
||||
applyRequestFilters
|
||||
};
|
||||
Reference in New Issue
Block a user