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:
+86
-2
@@ -51,6 +51,7 @@ flowchart TB
|
|||||||
dash["Dashboard Cards"]
|
dash["Dashboard Cards"]
|
||||||
status["Status Panel\n(Admin only)"]
|
status["Status Panel\n(Admin only)"]
|
||||||
history["History Tab"]
|
history["History Tab"]
|
||||||
|
requests["Requests Tab\n+ Filters / Search"]
|
||||||
webhooks["Webhook Config"]
|
webhooks["Webhook Config"]
|
||||||
swagger["Swagger UI\n/api/swagger"]
|
swagger["Swagger UI\n/api/swagger"]
|
||||||
end
|
end
|
||||||
@@ -62,6 +63,7 @@ flowchart TB
|
|||||||
dash_r["Dashboard Routes\n/api/dashboard (SSE /stream)"]
|
dash_r["Dashboard Routes\n/api/dashboard (SSE /stream)"]
|
||||||
stat_r["Status Routes\n/api/status"]
|
stat_r["Status Routes\n/api/status"]
|
||||||
wh_r["Webhook Routes\n/api/webhook/sonarr|radarr"]
|
wh_r["Webhook Routes\n/api/webhook/sonarr|radarr"]
|
||||||
|
ombi_r["Ombi Routes\n/api/ombi"]
|
||||||
hist_r["History Routes\n/api/history"]
|
hist_r["History Routes\n/api/history"]
|
||||||
proxy_r["Proxy Routes\n/api/sonarr · /api/radarr\n/api/sabnzbd · /api/emby"]
|
proxy_r["Proxy Routes\n/api/sonarr · /api/radarr\n/api/sabnzbd · /api/emby"]
|
||||||
|
|
||||||
@@ -82,12 +84,14 @@ flowchart TB
|
|||||||
rtorrent["rTorrent"]
|
rtorrent["rTorrent"]
|
||||||
transmission["Transmission"]
|
transmission["Transmission"]
|
||||||
emby["Emby / Jellyfin"]
|
emby["Emby / Jellyfin"]
|
||||||
|
ombi["Ombi"]
|
||||||
end
|
end
|
||||||
|
|
||||||
login -->|"POST /api/auth/login"| auth_r
|
login -->|"POST /api/auth/login"| auth_r
|
||||||
dash -->|"GET /api/dashboard/stream (SSE)"| dash_r
|
dash -->|"GET /api/dashboard/stream (SSE)"| dash_r
|
||||||
status -->|"GET /api/status"| stat_r
|
status -->|"GET /api/status"| stat_r
|
||||||
history -->|"GET /api/history/recent"| hist_r
|
history -->|"GET /api/history/recent"| hist_r
|
||||||
|
requests -->|"GET /api/ombi/requests"| ombi_r
|
||||||
|
|
||||||
auth_r --> tokenstore
|
auth_r --> tokenstore
|
||||||
auth_r -->|"authenticate"| emby
|
auth_r -->|"authenticate"| emby
|
||||||
@@ -97,13 +101,14 @@ flowchart TB
|
|||||||
stat_r --> cache
|
stat_r --> cache
|
||||||
wh_r --> cache
|
wh_r --> cache
|
||||||
wh_r --> paldra
|
wh_r --> paldra
|
||||||
|
ombi_r --> paldra
|
||||||
hist_r --> cache
|
hist_r --> cache
|
||||||
proxy_r -->|"proxy"| sonarr & radarr & sab & emby
|
proxy_r -->|"proxy"| sonarr & radarr & sab & emby
|
||||||
|
|
||||||
poller --> pdca & paldra
|
poller --> pdca & paldra
|
||||||
poller --> cache
|
poller --> cache
|
||||||
pdca -->|"HTTP/API"| sab & qbt & rtorrent & transmission
|
pdca -->|"HTTP/API"| sab & qbt & rtorrent & transmission
|
||||||
paldra -->|"HTTP/API"| sonarr & radarr
|
paldra -->|"HTTP/API"| sonarr & radarr & ombi
|
||||||
|
|
||||||
sonarr & radarr -->|"POST /api/webhook/*"| wh_r
|
sonarr & radarr -->|"POST /api/webhook/*"| wh_r
|
||||||
```
|
```
|
||||||
@@ -130,6 +135,7 @@ Express Server (:3001)
|
|||||||
├── /api/dashboard → requireAuth → read cache → DownloadBuilder → SSE/JSON
|
├── /api/dashboard → requireAuth → read cache → DownloadBuilder → SSE/JSON
|
||||||
├── /api/status → requireAuth → admin cache/polling/webhook status
|
├── /api/status → requireAuth → admin cache/polling/webhook status
|
||||||
├── /api/history → requireAuth → historyFetcher (5 min cache) → filter + dedup
|
├── /api/history → requireAuth → historyFetcher (5 min cache) → filter + dedup
|
||||||
|
├── /api/ombi → requireAuth → PALDRA → filter/sort/search → JSON
|
||||||
├── /api/sonarr|radarr → requireAuth → verifyCsrf → proxy to *arr API
|
├── /api/sonarr|radarr → requireAuth → verifyCsrf → proxy to *arr API
|
||||||
└── /api/sabnzbd|emby → requireAuth → verifyCsrf → proxy
|
└── /api/sabnzbd|emby → requireAuth → verifyCsrf → proxy
|
||||||
|
|
||||||
@@ -300,6 +306,17 @@ arrRetrieverRegistry = {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Ombi retriever
|
||||||
|
|
||||||
|
The `OmbiRetriever` (in `server/clients/OmbiClient.js`) fetches from:
|
||||||
|
|
||||||
|
| Task | Endpoint | Notes |
|
||||||
|
|------|----------|-------|
|
||||||
|
| Movie requests | `GET /api/v1/Request/movie` | Returns full movie request objects |
|
||||||
|
| TV requests | `GET /api/v1/Request/tv` | Returns full TV request objects |
|
||||||
|
|
||||||
|
Results are cached under `poll:ombi` and broadcast via SSE as `ombiRequests: { movie, tv }`. The client applies the same `ombiFilters.js` logic used by the server route, keeping behaviour consistent across both layers.
|
||||||
|
|
||||||
Each result element is `{ instance: instanceId, data: <arr API response> }`, allowing callers to look up instance credentials from `config.js`.
|
Each result element is `{ instance: instanceId, data: <arr API response> }`, allowing callers to look up instance credentials from `config.js`.
|
||||||
|
|
||||||
#### Retriever API Calls
|
#### Retriever API Calls
|
||||||
@@ -539,7 +556,12 @@ The browser's native `EventSource` API handles reconnection automatically on net
|
|||||||
id: string, // Instance identifier
|
id: string, // Instance identifier
|
||||||
name: string, // Instance display name
|
name: string, // Instance display name
|
||||||
type: string // Client type ('sabnzbd', 'qbittorrent', 'transmission', 'rtorrent')
|
type: string // Client type ('sabnzbd', 'qbittorrent', 'transmission', 'rtorrent')
|
||||||
}[]
|
}[],
|
||||||
|
ombiRequests: { // Raw Ombi movie + TV requests (client applies filters)
|
||||||
|
movie: OmbiRequest[],
|
||||||
|
tv: OmbiRequest[]
|
||||||
|
},
|
||||||
|
ombiBaseUrl: string // Ombi instance base URL for deep links
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -631,6 +653,67 @@ Matched download objects include `client`, `instanceId`, and `instanceName` fiel
|
|||||||
| `addedOn` | number/null | (qBittorrent) Unix timestamp when torrent was added |
|
| `addedOn` | number/null | (qBittorrent) Unix timestamp when torrent was added |
|
||||||
| `availableForUpgrade` | boolean/undefined | (History) `true` when outcome is `failed` but content is on disk |
|
| `availableForUpgrade` | boolean/undefined | (History) `true` when outcome is `failed` but content is on disk |
|
||||||
|
|
||||||
|
### 5.5 Ombi Request Filtering
|
||||||
|
|
||||||
|
The Ombi Requests tab displays movie and TV requests from Ombi. Filtering, sorting, and text search are applied **server-side** on the REST endpoint (`GET /api/ombi/requests`) and **client-side** on every SSE update. This dual-layer approach ensures external API consumers receive pre-filtered data while the SPA remains responsive without extra round-trips.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client as Browser (Requests Tab)
|
||||||
|
participant SSE as SSE /api/dashboard/stream
|
||||||
|
participant Route as /api/ombi/requests
|
||||||
|
participant Filters as ombiFilters (shared)
|
||||||
|
participant PALDRA as PALDRA Registry
|
||||||
|
participant Ombi as Ombi API
|
||||||
|
|
||||||
|
Note over Client: Initial load
|
||||||
|
Client->>Route: GET /api/ombi/requests?type=…&status=…&sort=…&search=…
|
||||||
|
Route->>PALDRA: getOmbiRequests()
|
||||||
|
PALDRA->>Ombi: GET /api/v1/Request/movie + /tv
|
||||||
|
Ombi-->>PALDRA: raw request arrays
|
||||||
|
PALDRA-->>Route: { movie: [], tv: [] }
|
||||||
|
Route->>Filters: applyRequestFilters()
|
||||||
|
Filters-->>Route: filtered & sorted requests
|
||||||
|
Route-->>Client: { requests: { movie, tv }, total }
|
||||||
|
|
||||||
|
Note over Client: Real-time updates
|
||||||
|
SSE->>Client: push raw ombiRequests + ombiBaseUrl
|
||||||
|
Client->>Filters: applyRequestFilters() (same code)
|
||||||
|
Filters-->>Client: filtered & sorted requests
|
||||||
|
Client->>Client: renderRequests()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Query parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
|-----------|------|---------|-------------|
|
||||||
|
| `type` | `movie` \| `tv` \| `all` | `all` | Media type filter (multi-select) |
|
||||||
|
| `status` | `pending` \| `approved` \| `available` \| `denied` | — | Request status filter (multi-select) |
|
||||||
|
| `sort` | `requestedDate_desc` \| `requestedDate_asc` \| `title_asc` \| `title_desc` | `requestedDate_desc` | Sort mode |
|
||||||
|
| `search` | string | — | Case-insensitive title substring |
|
||||||
|
| `showAll` | `'true'` \| `'false'` | `'false'` | Admin only: show all users' requests |
|
||||||
|
|
||||||
|
#### Status priority
|
||||||
|
|
||||||
|
The same `getRequestStatus()` function runs on both server and client:
|
||||||
|
|
||||||
|
1. `available` — if `available === true`
|
||||||
|
2. `denied` — if `denied === true`
|
||||||
|
3. `approved` — if `approved === true`
|
||||||
|
4. `pending` — if `requested === true`
|
||||||
|
5. `unknown` — fallback
|
||||||
|
|
||||||
|
#### Persistence
|
||||||
|
|
||||||
|
Filter and sort preferences are persisted in `localStorage` under the following keys:
|
||||||
|
|
||||||
|
| Key | Content |
|
||||||
|
|-----|---------|
|
||||||
|
| `sofarr-request-types` | `['movie', 'tv']` or subset |
|
||||||
|
| `sofarr-request-statuses` | `['pending', 'approved', 'available', 'denied']` or subset |
|
||||||
|
| `sofarr-request-sort` | `requestedDate_desc`, `requestedDate_asc`, `title_asc`, `title_desc` |
|
||||||
|
| `sofarr-request-search` | Free-text query string |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Caching and Smart Polling
|
## 6. Caching and Smart Polling
|
||||||
@@ -667,6 +750,7 @@ class MemoryCache {
|
|||||||
| `poll:radarr-history` | `{ records }` — lightweight | `POLL_INTERVAL × 3` |
|
| `poll:radarr-history` | `{ records }` — lightweight | `POLL_INTERVAL × 3` |
|
||||||
| `poll:radarr-tags` | `[{ instance, data: [{id, label}] }]` | `POLL_INTERVAL × 3` |
|
| `poll:radarr-tags` | `[{ instance, data: [{id, label}] }]` | `POLL_INTERVAL × 3` |
|
||||||
| `poll:qbittorrent` | `[torrent, …]` | `POLL_INTERVAL × 3` |
|
| `poll:qbittorrent` | `[torrent, …]` | `POLL_INTERVAL × 3` |
|
||||||
|
| `poll:ombi` | `{ movie: [], tv: [] }` | `POLL_INTERVAL × 3` |
|
||||||
| `history:sonarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min |
|
| `history:sonarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min |
|
||||||
| `history:radarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min |
|
| `history:radarr` | `[record, …]` flat array with `_instanceUrl`/`_instanceName` | 5 min |
|
||||||
| `emby:users` | `Map<lowerName, displayName>` | 60 s |
|
| `emby:users` | `Map<lowerName, displayName>` | 60 s |
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// Bootstrap - wire all event handlers on DOMContentLoaded
|
// Bootstrap - wire all event handlers on DOMContentLoaded
|
||||||
import { checkAuthenticationAndInit, handleLogin, handleLogoutClick } from './ui/auth.js';
|
import { checkAuthenticationAndInit, handleLogin, handleLogoutClick } from './ui/auth.js';
|
||||||
import { initDownloadClientFilter } from './ui/filters.js';
|
import { initDownloadClientFilter } from './ui/filters.js';
|
||||||
|
import { initRequestFilters } from './ui/requestFilters.js';
|
||||||
import { initHistoryControls } from './ui/history.js';
|
import { initHistoryControls } from './ui/history.js';
|
||||||
import { toggleStatusPanel } from './ui/statusPanel.js';
|
import { toggleStatusPanel } from './ui/statusPanel.js';
|
||||||
import { initWebhooks } from './ui/webhooks.js';
|
import { initWebhooks } from './ui/webhooks.js';
|
||||||
@@ -46,6 +47,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
initThemeSwitcher();
|
initThemeSwitcher();
|
||||||
initTabs();
|
initTabs();
|
||||||
initDownloadClientFilter();
|
initDownloadClientFilter();
|
||||||
|
initRequestFilters();
|
||||||
initHistoryControls();
|
initHistoryControls();
|
||||||
initWebhooks();
|
initWebhooks();
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -31,7 +31,13 @@ export const state = {
|
|||||||
sonarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
|
sonarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
|
||||||
radarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
|
radarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
|
||||||
ombiWebhook: { enabled: false, triggers: { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }, stats: null },
|
ombiWebhook: { enabled: false, triggers: { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }, stats: null },
|
||||||
webhookMetrics: null
|
webhookMetrics: null,
|
||||||
|
|
||||||
|
// Request filter state
|
||||||
|
selectedRequestTypes: ['movie', 'tv'],
|
||||||
|
selectedRequestStatuses: [],
|
||||||
|
requestSortMode: 'requestedDate_desc',
|
||||||
|
requestSearchQuery: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
|
||||||
|
import { state } from '../state.js';
|
||||||
|
import {
|
||||||
|
saveRequestTypes,
|
||||||
|
saveRequestStatuses,
|
||||||
|
saveRequestSort,
|
||||||
|
saveRequestSearch
|
||||||
|
} from '../utils/storage.js';
|
||||||
|
import { renderRequests } from './requests.js';
|
||||||
|
|
||||||
|
// ---- Type filter dropdown ----
|
||||||
|
|
||||||
|
function initTypeFilter() {
|
||||||
|
const btn = document.getElementById('request-type-filter-btn');
|
||||||
|
const dropdown = document.getElementById('request-type-filter-dropdown');
|
||||||
|
const selectAll = document.getElementById('request-type-select-all');
|
||||||
|
const deselectAll = document.getElementById('request-type-deselect-all');
|
||||||
|
|
||||||
|
if (!btn || !dropdown) return;
|
||||||
|
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
dropdown.classList.toggle('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
selectAll?.addEventListener('click', () => setAllTypes(true));
|
||||||
|
deselectAll?.addEventListener('click', () => setAllTypes(false));
|
||||||
|
|
||||||
|
// Wire up checkboxes
|
||||||
|
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
const value = cb.closest('.request-filter-option').dataset.value;
|
||||||
|
toggleType(value, cb.checked);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
updateTypeFilterUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAllTypes(checked) {
|
||||||
|
const dropdown = document.getElementById('request-type-filter-dropdown');
|
||||||
|
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
|
||||||
|
const newTypes = [];
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.checked = checked;
|
||||||
|
if (checked) newTypes.push(cb.closest('.request-filter-option').dataset.value);
|
||||||
|
});
|
||||||
|
state.selectedRequestTypes = checked ? newTypes : [];
|
||||||
|
saveRequestTypes(state.selectedRequestTypes);
|
||||||
|
updateTypeFilterUI();
|
||||||
|
renderRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleType(value, checked) {
|
||||||
|
const idx = state.selectedRequestTypes.indexOf(value);
|
||||||
|
if (checked && idx === -1) {
|
||||||
|
state.selectedRequestTypes.push(value);
|
||||||
|
} else if (!checked && idx > -1) {
|
||||||
|
state.selectedRequestTypes.splice(idx, 1);
|
||||||
|
}
|
||||||
|
saveRequestTypes(state.selectedRequestTypes);
|
||||||
|
updateTypeFilterUI();
|
||||||
|
renderRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTypeFilterUI() {
|
||||||
|
const text = document.getElementById('request-type-selected-text');
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const dropdown = document.getElementById('request-type-filter-dropdown');
|
||||||
|
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
const value = cb.closest('.request-filter-option').dataset.value;
|
||||||
|
cb.checked = state.selectedRequestTypes.includes(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.selectedRequestTypes.length === 0) {
|
||||||
|
text.textContent = 'None';
|
||||||
|
} else if (state.selectedRequestTypes.length === checkboxes.length) {
|
||||||
|
text.textContent = 'All';
|
||||||
|
} else {
|
||||||
|
text.textContent = state.selectedRequestTypes.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Status filter dropdown ----
|
||||||
|
|
||||||
|
function initStatusFilter() {
|
||||||
|
const btn = document.getElementById('request-status-filter-btn');
|
||||||
|
const dropdown = document.getElementById('request-status-filter-dropdown');
|
||||||
|
const selectAll = document.getElementById('request-status-select-all');
|
||||||
|
const deselectAll = document.getElementById('request-status-deselect-all');
|
||||||
|
|
||||||
|
if (!btn || !dropdown) return;
|
||||||
|
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
dropdown.classList.toggle('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
selectAll?.addEventListener('click', () => setAllStatuses(true));
|
||||||
|
deselectAll?.addEventListener('click', () => setAllStatuses(false));
|
||||||
|
|
||||||
|
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
const value = cb.closest('.request-filter-option').dataset.value;
|
||||||
|
toggleStatus(value, cb.checked);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
updateStatusFilterUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAllStatuses(checked) {
|
||||||
|
const dropdown = document.getElementById('request-status-filter-dropdown');
|
||||||
|
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
|
||||||
|
const newStatuses = [];
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.checked = checked;
|
||||||
|
if (checked) newStatuses.push(cb.closest('.request-filter-option').dataset.value);
|
||||||
|
});
|
||||||
|
state.selectedRequestStatuses = checked ? newStatuses : [];
|
||||||
|
saveRequestStatuses(state.selectedRequestStatuses);
|
||||||
|
updateStatusFilterUI();
|
||||||
|
renderRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStatus(value, checked) {
|
||||||
|
const idx = state.selectedRequestStatuses.indexOf(value);
|
||||||
|
if (checked && idx === -1) {
|
||||||
|
state.selectedRequestStatuses.push(value);
|
||||||
|
} else if (!checked && idx > -1) {
|
||||||
|
state.selectedRequestStatuses.splice(idx, 1);
|
||||||
|
}
|
||||||
|
saveRequestStatuses(state.selectedRequestStatuses);
|
||||||
|
updateStatusFilterUI();
|
||||||
|
renderRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatusFilterUI() {
|
||||||
|
const text = document.getElementById('request-status-selected-text');
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const dropdown = document.getElementById('request-status-filter-dropdown');
|
||||||
|
const checkboxes = dropdown.querySelectorAll('.request-filter-checkbox');
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
const value = cb.closest('.request-filter-option').dataset.value;
|
||||||
|
cb.checked = state.selectedRequestStatuses.includes(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.selectedRequestStatuses.length === 0) {
|
||||||
|
text.textContent = 'All';
|
||||||
|
} else if (state.selectedRequestStatuses.length === checkboxes.length) {
|
||||||
|
text.textContent = 'All';
|
||||||
|
} else {
|
||||||
|
text.textContent = state.selectedRequestStatuses.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Sort select ----
|
||||||
|
|
||||||
|
function initSortSelect() {
|
||||||
|
const select = document.getElementById('request-sort-select');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
select.value = state.requestSortMode;
|
||||||
|
select.addEventListener('change', (e) => {
|
||||||
|
state.requestSortMode = e.target.value;
|
||||||
|
saveRequestSort(state.requestSortMode);
|
||||||
|
renderRequests();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Search input ----
|
||||||
|
|
||||||
|
function initSearchInput() {
|
||||||
|
const input = document.getElementById('request-search-input');
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
input.value = state.requestSearchQuery;
|
||||||
|
|
||||||
|
let debounceTimer;
|
||||||
|
input.addEventListener('input', (e) => {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
state.requestSearchQuery = e.target.value;
|
||||||
|
saveRequestSearch(state.requestSearchQuery);
|
||||||
|
renderRequests();
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Global click-outside handler ----
|
||||||
|
|
||||||
|
function initClickOutside() {
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const typeDropdown = document.getElementById('request-type-filter-dropdown');
|
||||||
|
const typeBtn = document.getElementById('request-type-filter-btn');
|
||||||
|
const statusDropdown = document.getElementById('request-status-filter-dropdown');
|
||||||
|
const statusBtn = document.getElementById('request-status-filter-btn');
|
||||||
|
|
||||||
|
if (typeDropdown && !typeDropdown.contains(e.target) && e.target !== typeBtn && !typeBtn?.contains(e.target)) {
|
||||||
|
typeDropdown.classList.remove('open');
|
||||||
|
}
|
||||||
|
if (statusDropdown && !statusDropdown.contains(e.target) && e.target !== statusBtn && !statusBtn?.contains(e.target)) {
|
||||||
|
statusDropdown.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Public API ----
|
||||||
|
|
||||||
|
export function initRequestFilters() {
|
||||||
|
initTypeFilter();
|
||||||
|
initStatusFilter();
|
||||||
|
initSortSelect();
|
||||||
|
initSearchInput();
|
||||||
|
initClickOutside();
|
||||||
|
|
||||||
|
// Listen for SSE updates (registered once on app bootstrap)
|
||||||
|
document.addEventListener('ombiRequestsUpdated', () => {
|
||||||
|
renderRequests();
|
||||||
|
});
|
||||||
|
}
|
||||||
+35
-42
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
import { state } from '../state.js';
|
import { state } from '../state.js';
|
||||||
import { escapeHtml } from '../utils/format.js';
|
import { escapeHtml } from '../utils/format.js';
|
||||||
|
import { applyRequestFilters, getRequestStatus } from '../utils/ombiFilters.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to extract the username from an Ombi request object.
|
* Helper function to extract the username from an Ombi request object.
|
||||||
* The Ombi API returns requestedUser as an OmbiStore.Entities.OmbiUser object,
|
* The Ombi API returns requestedUser as an OmbiStore.Entities.OmbiUser object,
|
||||||
* not a string, so we need to extract the username from the object.
|
* not a string, so we need to extract the username from the object.
|
||||||
*
|
*
|
||||||
|
* Must stay in sync with server/utils/ombiHelpers.js
|
||||||
|
*
|
||||||
* @param {Object} request - The Ombi request object
|
* @param {Object} request - The Ombi request object
|
||||||
* @returns {string} The extracted username, or empty string if not found
|
* @returns {string} The extracted username, or empty string if not found
|
||||||
*/
|
*/
|
||||||
@@ -38,16 +41,34 @@ export function renderRequests() {
|
|||||||
...ombiRequests.tv.map(r => ({ ...r, mediaType: 'tv' }))
|
...ombiRequests.tv.map(r => ({ ...r, mediaType: 'tv' }))
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Apply client-side filters, sorting, and search
|
||||||
|
const filtered = applyRequestFilters(allRequests, {
|
||||||
|
types: state.selectedRequestTypes,
|
||||||
|
statuses: state.selectedRequestStatuses,
|
||||||
|
sort: state.requestSortMode,
|
||||||
|
search: state.requestSearchQuery
|
||||||
|
});
|
||||||
|
|
||||||
requestsList.innerHTML = '';
|
requestsList.innerHTML = '';
|
||||||
|
|
||||||
if (allRequests.length === 0) {
|
if (filtered.length === 0) {
|
||||||
if (noRequests) noRequests.style.display = 'block';
|
if (noRequests) {
|
||||||
|
noRequests.style.display = 'block';
|
||||||
|
const p = noRequests.querySelector('p');
|
||||||
|
if (p) {
|
||||||
|
// Differentiate between no data from Ombi vs filters excluded everything
|
||||||
|
const hasAnyData = allRequests.length > 0;
|
||||||
|
p.textContent = hasAnyData
|
||||||
|
? 'No requests match your filters.'
|
||||||
|
: 'No requests found.';
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (noRequests) noRequests.style.display = 'none';
|
if (noRequests) noRequests.style.display = 'none';
|
||||||
|
|
||||||
allRequests.forEach(request => {
|
filtered.forEach(request => {
|
||||||
const card = createRequestCard(request);
|
const card = createRequestCard(request);
|
||||||
requestsList.appendChild(card);
|
requestsList.appendChild(card);
|
||||||
});
|
});
|
||||||
@@ -102,7 +123,7 @@ function createRequestCard(request) {
|
|||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'request-actions';
|
actions.className = 'request-actions';
|
||||||
|
|
||||||
if (request.theMovieDbId) {
|
if (state.ombiBaseUrl && request.theMovieDbId) {
|
||||||
const ombiLink = document.createElement('a');
|
const ombiLink = document.createElement('a');
|
||||||
ombiLink.className = 'request-link ombi-link';
|
ombiLink.className = 'request-link ombi-link';
|
||||||
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType}/${request.theMovieDbId}`;
|
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType}/${request.theMovieDbId}`;
|
||||||
@@ -129,46 +150,18 @@ function createStatusBadge(request) {
|
|||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
badge.className = 'request-status-badge';
|
badge.className = 'request-status-badge';
|
||||||
|
|
||||||
let status = 'unknown';
|
const status = getRequestStatus(request);
|
||||||
let text = 'Unknown';
|
const statusTexts = {
|
||||||
|
available: 'Available',
|
||||||
if (request.available) {
|
denied: `Denied: ${request.deniedReason || 'No reason'}`,
|
||||||
status = 'available';
|
approved: 'Approved',
|
||||||
text = 'Available';
|
pending: 'Pending',
|
||||||
} else if (request.approved) {
|
unknown: 'Unknown'
|
||||||
status = 'approved';
|
};
|
||||||
text = 'Approved';
|
|
||||||
} else if (request.denied) {
|
|
||||||
status = 'denied';
|
|
||||||
text = `Denied: ${request.deniedReason || 'No reason'}`;
|
|
||||||
} else if (request.requested) {
|
|
||||||
status = 'pending';
|
|
||||||
text = 'Pending';
|
|
||||||
}
|
|
||||||
|
|
||||||
badge.classList.add(status);
|
badge.classList.add(status);
|
||||||
badge.textContent = text;
|
badge.textContent = statusTexts[status] || 'Unknown';
|
||||||
|
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupRequestsTab() {
|
|
||||||
// Listen for SSE updates
|
|
||||||
if (state.sseSource) {
|
|
||||||
state.sseSource.addEventListener('message', (event) => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
if (data.ombiRequests) {
|
|
||||||
state.ombiRequests = data.ombiRequests;
|
|
||||||
renderRequests();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also listen for custom event triggered from sse.js
|
|
||||||
document.addEventListener('ombiRequestsUpdated', () => {
|
|
||||||
renderRequests();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial render
|
|
||||||
renderRequests();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { getActiveTab, saveActiveTab } from '../utils/storage.js';
|
import { getActiveTab, saveActiveTab } from '../utils/storage.js';
|
||||||
import { loadHistory } from './history.js';
|
import { loadHistory } from './history.js';
|
||||||
import { setupRequestsTab } from './requests.js';
|
|
||||||
|
|
||||||
export function initTabs() {
|
export function initTabs() {
|
||||||
const downloadsTab = document.querySelector('[data-tab="downloads"]');
|
const downloadsTab = document.querySelector('[data-tab="downloads"]');
|
||||||
@@ -55,7 +54,6 @@ export function activateTab(tab) {
|
|||||||
if (requestsSection) requestsSection.classList.remove('hidden');
|
if (requestsSection) requestsSection.classList.remove('hidden');
|
||||||
saveActiveTab('requests');
|
saveActiveTab('requests');
|
||||||
setupRequestsTab();
|
setupRequestsTab();
|
||||||
} else if (tab === 'history') {
|
|
||||||
if (historyTab) historyTab.classList.add('active');
|
if (historyTab) historyTab.classList.add('active');
|
||||||
if (historySection) historySection.classList.remove('hidden');
|
if (historySection) historySection.classList.remove('hidden');
|
||||||
saveActiveTab('history');
|
saveActiveTab('history');
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* Pure filter / sort / search utilities for Ombi requests.
|
||||||
|
* Must stay in sync with server/utils/ombiFilters.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a single status string from an Ombi request object.
|
||||||
|
* Priority: available > denied > approved > pending > unknown
|
||||||
|
*
|
||||||
|
* @param {Object} request
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export 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
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
export 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
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
export 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}
|
||||||
|
*/
|
||||||
|
export 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
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
export 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
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
@@ -46,6 +46,41 @@ import { state } from '../state.js';
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Load request filter preferences from localStorage
|
||||||
|
(function loadRequestFilters() {
|
||||||
|
try {
|
||||||
|
const savedTypes = localStorage.getItem('sofarr-request-types');
|
||||||
|
if (savedTypes) state.selectedRequestTypes = JSON.parse(savedTypes);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Storage] Failed to load request types:', e);
|
||||||
|
state.selectedRequestTypes = ['movie', 'tv'];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedStatuses = localStorage.getItem('sofarr-request-statuses');
|
||||||
|
if (savedStatuses) state.selectedRequestStatuses = JSON.parse(savedStatuses);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Storage] Failed to load request statuses:', e);
|
||||||
|
state.selectedRequestStatuses = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedSort = localStorage.getItem('sofarr-request-sort');
|
||||||
|
if (savedSort) state.requestSortMode = savedSort;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Storage] Failed to load request sort:', e);
|
||||||
|
state.requestSortMode = 'requestedDate_desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const savedSearch = localStorage.getItem('sofarr-request-search');
|
||||||
|
if (savedSearch !== null) state.requestSearchQuery = savedSearch;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Storage] Failed to load request search:', e);
|
||||||
|
state.requestSearchQuery = '';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
// Export helper functions for localStorage operations
|
// Export helper functions for localStorage operations
|
||||||
export function saveHistoryDays(days) {
|
export function saveHistoryDays(days) {
|
||||||
localStorage.setItem('sofarr-history-days', days);
|
localStorage.setItem('sofarr-history-days', days);
|
||||||
@@ -74,3 +109,19 @@ export function getActiveTab() {
|
|||||||
export function saveActiveTab(tab) {
|
export function saveActiveTab(tab) {
|
||||||
localStorage.setItem('sofarr-active-tab', tab);
|
localStorage.setItem('sofarr-active-tab', tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function saveRequestTypes(types) {
|
||||||
|
localStorage.setItem('sofarr-request-types', JSON.stringify(types));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveRequestStatuses(statuses) {
|
||||||
|
localStorage.setItem('sofarr-request-statuses', JSON.stringify(statuses));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveRequestSort(sort) {
|
||||||
|
localStorage.setItem('sofarr-request-sort', sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveRequestSearch(query) {
|
||||||
|
localStorage.setItem('sofarr-request-search', query);
|
||||||
|
}
|
||||||
|
|||||||
+20
-20
File diff suppressed because one or more lines are too long
+75
-1
@@ -201,7 +201,81 @@
|
|||||||
<div class="tab-panel hidden" id="tab-requests">
|
<div class="tab-panel hidden" id="tab-requests">
|
||||||
<div class="requests-container">
|
<div class="requests-container">
|
||||||
<div class="requests-header">
|
<div class="requests-header">
|
||||||
<h2>Ombi Requests</h2>
|
<div class="requests-controls">
|
||||||
|
<!-- Media Type Filter -->
|
||||||
|
<div class="request-filter" id="request-type-filter">
|
||||||
|
<label class="request-filter-label">Type:</label>
|
||||||
|
<button class="request-filter-btn" id="request-type-filter-btn" type="button" aria-expanded="false">
|
||||||
|
<span id="request-type-selected-text">All</span>
|
||||||
|
<span class="dropdown-arrow">▼</span>
|
||||||
|
</button>
|
||||||
|
<div class="request-filter-dropdown" id="request-type-filter-dropdown">
|
||||||
|
<div class="request-filter-dropdown-header">
|
||||||
|
<button class="request-filter-dropdown-btn-small" id="request-type-select-all" type="button">Select All</button>
|
||||||
|
<button class="request-filter-dropdown-btn-small" id="request-type-deselect-all" type="button">Deselect All</button>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-options" id="request-type-options">
|
||||||
|
<div class="request-filter-option" data-value="movie">
|
||||||
|
<input type="checkbox" id="request-type-movie" class="request-filter-checkbox" checked>
|
||||||
|
<label for="request-type-movie">Movies</label>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-option" data-value="tv">
|
||||||
|
<input type="checkbox" id="request-type-tv" class="request-filter-checkbox" checked>
|
||||||
|
<label for="request-type-tv">TV Shows</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Filter -->
|
||||||
|
<div class="request-filter" id="request-status-filter">
|
||||||
|
<label class="request-filter-label">Status:</label>
|
||||||
|
<button class="request-filter-btn" id="request-status-filter-btn" type="button" aria-expanded="false">
|
||||||
|
<span id="request-status-selected-text">All</span>
|
||||||
|
<span class="dropdown-arrow">▼</span>
|
||||||
|
</button>
|
||||||
|
<div class="request-filter-dropdown" id="request-status-filter-dropdown">
|
||||||
|
<div class="request-filter-dropdown-header">
|
||||||
|
<button class="request-filter-dropdown-btn-small" id="request-status-select-all" type="button">Select All</button>
|
||||||
|
<button class="request-filter-dropdown-btn-small" id="request-status-deselect-all" type="button">Deselect All</button>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-options" id="request-status-options">
|
||||||
|
<div class="request-filter-option" data-value="pending">
|
||||||
|
<input type="checkbox" id="request-status-pending" class="request-filter-checkbox">
|
||||||
|
<label for="request-status-pending">Pending</label>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-option" data-value="approved">
|
||||||
|
<input type="checkbox" id="request-status-approved" class="request-filter-checkbox">
|
||||||
|
<label for="request-status-approved">Approved</label>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-option" data-value="available">
|
||||||
|
<input type="checkbox" id="request-status-available" class="request-filter-checkbox">
|
||||||
|
<label for="request-status-available">Available</label>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-option" data-value="denied">
|
||||||
|
<input type="checkbox" id="request-status-denied" class="request-filter-checkbox">
|
||||||
|
<label for="request-status-denied">Denied</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sort Dropdown -->
|
||||||
|
<div class="request-sort">
|
||||||
|
<label class="request-filter-label" for="request-sort-select">Sort:</label>
|
||||||
|
<select id="request-sort-select" class="request-sort-select">
|
||||||
|
<option value="requestedDate_desc">Newest to oldest</option>
|
||||||
|
<option value="requestedDate_asc">Oldest to newest</option>
|
||||||
|
<option value="title_asc">A–Z</option>
|
||||||
|
<option value="title_desc">Z–A</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div class="request-search">
|
||||||
|
<input type="text" id="request-search-input" class="request-search-input" placeholder="Search by title...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="no-requests" class="no-requests hidden">
|
<div id="no-requests" class="no-requests hidden">
|
||||||
<p>No requests found.</p>
|
<p>No requests found.</p>
|
||||||
|
|||||||
@@ -895,6 +895,186 @@ body {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Request Filters ===== */
|
||||||
|
|
||||||
|
.requests-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requests-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-filter {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-filter-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-filter-btn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 100px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-filter-btn:hover {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-filter-btn:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-filter-btn .dropdown-arrow {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-filter-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 280px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-filter-dropdown.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-filter-dropdown-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-filter-dropdown-btn-small {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-filter-dropdown-btn-small:hover {
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-filter-options {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-filter-option {
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-filter-option:hover {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-filter-checkbox {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-filter-option label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-sort {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-sort-select {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-sort-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-search {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-search-input {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.history-header {
|
.history-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -218,6 +218,58 @@ components:
|
|||||||
description: Additional error details (dev-only)
|
description: Additional error details (dev-only)
|
||||||
example: "Emby API returned 401"
|
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:
|
BlocklistSearchRequest:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
|
|||||||
+85
-15
@@ -5,6 +5,7 @@ const cache = require('../utils/cache');
|
|||||||
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
|
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const { extractRequestedUser } = require('../utils/ombiHelpers');
|
const { extractRequestedUser } = require('../utils/ombiHelpers');
|
||||||
|
const { applyRequestFilters } = require('../utils/ombiFilters');
|
||||||
|
|
||||||
const router = express.Router();
|
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
|
* Returns Ombi movie and TV requests. Non-admin users only see their own requests
|
||||||
* (filtered by Emby user mapping), while admins see all 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.
|
* **Authentication:** Requires cookie authentication.
|
||||||
* security:
|
* security:
|
||||||
* - cookieAuth: []
|
* - 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:
|
* responses:
|
||||||
* '200':
|
* '200':
|
||||||
* description: Ombi requests retrieved successfully
|
* description: Ombi requests retrieved successfully
|
||||||
@@ -35,17 +79,20 @@ const router = express.Router();
|
|||||||
* isAdmin:
|
* isAdmin:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
* example: false
|
* example: false
|
||||||
|
* showAll:
|
||||||
|
* type: boolean
|
||||||
|
* example: false
|
||||||
* requests:
|
* requests:
|
||||||
* type: object
|
* type: object
|
||||||
* properties:
|
* properties:
|
||||||
* movie:
|
* movie:
|
||||||
* type: array
|
* type: array
|
||||||
* items:
|
* items:
|
||||||
* type: object
|
* $ref: '#/components/schemas/OmbiRequest'
|
||||||
* tv:
|
* tv:
|
||||||
* type: array
|
* type: array
|
||||||
* items:
|
* items:
|
||||||
* type: object
|
* $ref: '#/components/schemas/OmbiRequest'
|
||||||
* total:
|
* total:
|
||||||
* type: integer
|
* type: integer
|
||||||
* example: 5
|
* example: 5
|
||||||
@@ -59,11 +106,12 @@ const router = express.Router();
|
|||||||
router.get('/requests', requireAuth, async (req, res) => {
|
router.get('/requests', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
const isAdmin = req.isAdmin;
|
const isAdmin = user.isAdmin;
|
||||||
const username = user.name;
|
const username = user.name;
|
||||||
const showAll = isAdmin && req.query.showAll === 'true';
|
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();
|
await arrRetrieverRegistry.initialize();
|
||||||
|
|
||||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
|
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
|
||||||
@@ -73,31 +121,51 @@ router.get('/requests', requireAuth, async (req, res) => {
|
|||||||
let filteredTvRequests = ombiRequests.tv || [];
|
let filteredTvRequests = ombiRequests.tv || [];
|
||||||
|
|
||||||
if (!showAll && username) {
|
if (!showAll && username) {
|
||||||
// Ombi uses requestedUser field to track who made the request
|
|
||||||
// Match by username (case-insensitive)
|
|
||||||
const usernameLower = username.toLowerCase();
|
const usernameLower = username.toLowerCase();
|
||||||
|
|
||||||
filteredMovieRequests = filteredMovieRequests.filter(req => {
|
// Filter to show only the current user's requests.
|
||||||
const requestedUser = extractRequestedUser(req);
|
// Requests with no identifiable user (empty string) are hidden from non-admins.
|
||||||
|
filteredMovieRequests = filteredMovieRequests.filter(reqItem => {
|
||||||
|
const requestedUser = extractRequestedUser(reqItem);
|
||||||
return requestedUser.toLowerCase() === usernameLower;
|
return requestedUser.toLowerCase() === usernameLower;
|
||||||
});
|
});
|
||||||
|
|
||||||
filteredTvRequests = filteredTvRequests.filter(req => {
|
filteredTvRequests = filteredTvRequests.filter(reqItem => {
|
||||||
const requestedUser = extractRequestedUser(req);
|
const requestedUser = extractRequestedUser(reqItem);
|
||||||
return requestedUser.toLowerCase() === usernameLower;
|
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({
|
res.json({
|
||||||
user: username,
|
user: username,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
showAll,
|
showAll,
|
||||||
requests: {
|
requests: { movie, tv },
|
||||||
movie: filteredMovieRequests,
|
|
||||||
tv: filteredTvRequests
|
|
||||||
},
|
|
||||||
total
|
total
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -330,6 +398,8 @@ router.get('/webhook/status', requireAuth, async (req, res) => {
|
|||||||
enabled: webhookConfig.enabled || false,
|
enabled: webhookConfig.enabled || false,
|
||||||
webhookUrl: webhookConfig.webhookUrl || null,
|
webhookUrl: webhookConfig.webhookUrl || null,
|
||||||
applicationToken: webhookConfig.applicationToken || 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: {
|
triggers: {
|
||||||
requestAvailable: webhookConfig.enabled || false,
|
requestAvailable: webhookConfig.enabled || false,
|
||||||
requestApproved: 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
|
||||||
|
};
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
* Tests for client/src/ui/requestFilters.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { state } from '../../../client/src/state.js';
|
||||||
|
import { initRequestFilters } from '../../../client/src/ui/requestFilters.js';
|
||||||
|
import { renderRequests } from '../../../client/src/ui/requests.js';
|
||||||
|
|
||||||
|
// Mock renderRequests to verify re-render triggers
|
||||||
|
vi.mock('../../../client/src/ui/requests.js', () => ({
|
||||||
|
renderRequests: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store = {};
|
||||||
|
return {
|
||||||
|
getItem: (key) => store[key] || null,
|
||||||
|
setItem: (key, value) => { store[key] = value; },
|
||||||
|
removeItem: (key) => { delete store[key]; },
|
||||||
|
clear: () => { store = {}; }
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
value: localStorageMock
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupDOM() {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="requests-controls">
|
||||||
|
<div class="request-filter" id="request-type-filter">
|
||||||
|
<button class="request-filter-btn" id="request-type-filter-btn" type="button">
|
||||||
|
<span id="request-type-selected-text">All</span>
|
||||||
|
<span class="dropdown-arrow">▼</span>
|
||||||
|
</button>
|
||||||
|
<div class="request-filter-dropdown" id="request-type-filter-dropdown">
|
||||||
|
<div class="request-filter-dropdown-header">
|
||||||
|
<button id="request-type-select-all" type="button">Select All</button>
|
||||||
|
<button id="request-type-deselect-all" type="button">Deselect All</button>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-options" id="request-type-options">
|
||||||
|
<div class="request-filter-option" data-value="movie">
|
||||||
|
<input type="checkbox" id="request-type-movie" class="request-filter-checkbox" checked>
|
||||||
|
<label for="request-type-movie">Movies</label>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-option" data-value="tv">
|
||||||
|
<input type="checkbox" id="request-type-tv" class="request-filter-checkbox" checked>
|
||||||
|
<label for="request-type-tv">TV Shows</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="request-filter" id="request-status-filter">
|
||||||
|
<button class="request-filter-btn" id="request-status-filter-btn" type="button">
|
||||||
|
<span id="request-status-selected-text">All</span>
|
||||||
|
<span class="dropdown-arrow">▼</span>
|
||||||
|
</button>
|
||||||
|
<div class="request-filter-dropdown" id="request-status-filter-dropdown">
|
||||||
|
<div class="request-filter-dropdown-header">
|
||||||
|
<button id="request-status-select-all" type="button">Select All</button>
|
||||||
|
<button id="request-status-deselect-all" type="button">Deselect All</button>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-options" id="request-status-options">
|
||||||
|
<div class="request-filter-option" data-value="pending">
|
||||||
|
<input type="checkbox" id="request-status-pending" class="request-filter-checkbox">
|
||||||
|
<label for="request-status-pending">Pending</label>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-option" data-value="approved">
|
||||||
|
<input type="checkbox" id="request-status-approved" class="request-filter-checkbox">
|
||||||
|
<label for="request-status-approved">Approved</label>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-option" data-value="available">
|
||||||
|
<input type="checkbox" id="request-status-available" class="request-filter-checkbox">
|
||||||
|
<label for="request-status-available">Available</label>
|
||||||
|
</div>
|
||||||
|
<div class="request-filter-option" data-value="denied">
|
||||||
|
<input type="checkbox" id="request-status-denied" class="request-filter-checkbox">
|
||||||
|
<label for="request-status-denied">Denied</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="request-sort">
|
||||||
|
<select id="request-sort-select" class="request-sort-select">
|
||||||
|
<option value="requestedDate_desc">Newest to oldest</option>
|
||||||
|
<option value="requestedDate_asc">Oldest to newest</option>
|
||||||
|
<option value="title_asc">A–Z</option>
|
||||||
|
<option value="title_desc">Z–A</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="request-search">
|
||||||
|
<input type="text" id="request-search-input" class="request-search-input" placeholder="Search by title...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('initRequestFilters', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorageMock.clear();
|
||||||
|
state.selectedRequestTypes = ['movie', 'tv'];
|
||||||
|
state.selectedRequestStatuses = [];
|
||||||
|
state.requestSortMode = 'requestedDate_desc';
|
||||||
|
state.requestSearchQuery = '';
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setupDOM();
|
||||||
|
initRequestFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores saved type selections from localStorage', () => {
|
||||||
|
localStorageMock.setItem('sofarr-request-types', JSON.stringify(['tv']));
|
||||||
|
state.selectedRequestTypes = ['tv'];
|
||||||
|
setupDOM();
|
||||||
|
initRequestFilters();
|
||||||
|
|
||||||
|
const movieCb = document.getElementById('request-type-movie');
|
||||||
|
const tvCb = document.getElementById('request-type-tv');
|
||||||
|
expect(movieCb.checked).toBe(false);
|
||||||
|
expect(tvCb.checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores saved status selections from localStorage', () => {
|
||||||
|
localStorageMock.setItem('sofarr-request-statuses', JSON.stringify(['pending', 'approved']));
|
||||||
|
state.selectedRequestStatuses = ['pending', 'approved'];
|
||||||
|
setupDOM();
|
||||||
|
initRequestFilters();
|
||||||
|
|
||||||
|
expect(document.getElementById('request-status-pending').checked).toBe(true);
|
||||||
|
expect(document.getElementById('request-status-approved').checked).toBe(true);
|
||||||
|
expect(document.getElementById('request-status-available').checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores saved sort mode', () => {
|
||||||
|
localStorageMock.setItem('sofarr-request-sort', 'title_asc');
|
||||||
|
state.requestSortMode = 'title_asc';
|
||||||
|
setupDOM();
|
||||||
|
initRequestFilters();
|
||||||
|
|
||||||
|
expect(document.getElementById('request-sort-select').value).toBe('title_asc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores saved search query', () => {
|
||||||
|
localStorageMock.setItem('sofarr-request-search', 'batman');
|
||||||
|
state.requestSearchQuery = 'batman';
|
||||||
|
setupDOM();
|
||||||
|
initRequestFilters();
|
||||||
|
|
||||||
|
expect(document.getElementById('request-search-input').value).toBe('batman');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles type checkbox and updates state', () => {
|
||||||
|
const movieCb = document.getElementById('request-type-movie');
|
||||||
|
movieCb.click();
|
||||||
|
|
||||||
|
expect(state.selectedRequestTypes).toEqual(['tv']);
|
||||||
|
expect(localStorageMock.getItem('sofarr-request-types')).toBe(JSON.stringify(['tv']));
|
||||||
|
expect(renderRequests).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles status checkbox and updates state', () => {
|
||||||
|
const pendingCb = document.getElementById('request-status-pending');
|
||||||
|
pendingCb.click();
|
||||||
|
|
||||||
|
expect(state.selectedRequestStatuses).toEqual(['pending']);
|
||||||
|
expect(localStorageMock.getItem('sofarr-request-statuses')).toBe(JSON.stringify(['pending']));
|
||||||
|
expect(renderRequests).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select all sets all types', () => {
|
||||||
|
state.selectedRequestTypes = [];
|
||||||
|
setupDOM();
|
||||||
|
initRequestFilters();
|
||||||
|
|
||||||
|
document.getElementById('request-type-select-all').click();
|
||||||
|
|
||||||
|
expect(state.selectedRequestTypes).toEqual(['movie', 'tv']);
|
||||||
|
expect(renderRequests).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deselect all clears all types', () => {
|
||||||
|
document.getElementById('request-type-deselect-all').click();
|
||||||
|
|
||||||
|
expect(state.selectedRequestTypes).toEqual([]);
|
||||||
|
expect(renderRequests).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select all sets all statuses', () => {
|
||||||
|
document.getElementById('request-status-select-all').click();
|
||||||
|
|
||||||
|
expect(state.selectedRequestStatuses).toEqual(['pending', 'approved', 'available', 'denied']);
|
||||||
|
expect(renderRequests).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deselect all clears all statuses', () => {
|
||||||
|
state.selectedRequestStatuses = ['pending', 'approved'];
|
||||||
|
setupDOM();
|
||||||
|
initRequestFilters();
|
||||||
|
|
||||||
|
document.getElementById('request-status-deselect-all').click();
|
||||||
|
|
||||||
|
expect(state.selectedRequestStatuses).toEqual([]);
|
||||||
|
expect(renderRequests).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changing sort select updates state', () => {
|
||||||
|
const select = document.getElementById('request-sort-select');
|
||||||
|
select.value = 'title_asc';
|
||||||
|
select.dispatchEvent(new Event('change'));
|
||||||
|
|
||||||
|
expect(state.requestSortMode).toBe('title_asc');
|
||||||
|
expect(localStorageMock.getItem('sofarr-request-sort')).toBe('title_asc');
|
||||||
|
expect(renderRequests).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('typing in search input updates state after debounce', async () => {
|
||||||
|
const input = document.getElementById('request-search-input');
|
||||||
|
input.value = 'bat';
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
|
||||||
|
// State shouldn't update immediately due to debounce
|
||||||
|
expect(state.requestSearchQuery).toBe('');
|
||||||
|
expect(renderRequests).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Wait for debounce
|
||||||
|
await new Promise(r => setTimeout(r, 250));
|
||||||
|
|
||||||
|
expect(state.requestSearchQuery).toBe('bat');
|
||||||
|
expect(localStorageMock.getItem('sofarr-request-search')).toBe('bat');
|
||||||
|
expect(renderRequests).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking outside closes dropdowns', () => {
|
||||||
|
const typeDropdown = document.getElementById('request-type-filter-dropdown');
|
||||||
|
const typeBtn = document.getElementById('request-type-filter-btn');
|
||||||
|
|
||||||
|
typeBtn.click();
|
||||||
|
expect(typeDropdown.classList.contains('open')).toBe(true);
|
||||||
|
|
||||||
|
document.body.click();
|
||||||
|
expect(typeDropdown.classList.contains('open')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -143,6 +143,9 @@ afterEach(() => {
|
|||||||
// GET /api/ombi/requests
|
// GET /api/ombi/requests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TODO: Unskip these tests once arrRetrieverRegistry can be properly mocked.
|
||||||
|
// The tests need to control the return value of getOmbiRequests() to test
|
||||||
|
// user filtering, showAll parameter, and edge cases reliably.
|
||||||
describe.skip('GET /api/ombi/requests', () => {
|
describe.skip('GET /api/ombi/requests', () => {
|
||||||
let app;
|
let app;
|
||||||
|
|
||||||
@@ -387,6 +390,163 @@ describe.skip('GET /api/ombi/requests', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/ombi/requests — query param filtering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const FILTERED_MOVIE_REQUESTS = [
|
||||||
|
{ id: 1, title: 'The Batman', requestedDate: '2026-05-21T10:00:00.000Z', available: false, approved: true, denied: false, requested: true, theMovieDbId: 414906 },
|
||||||
|
{ id: 2, title: 'Batman Returns', requestedDate: '2026-05-10T10:00:00.000Z', available: true, approved: true, denied: false, requested: true, theMovieDbId: 414907 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const FILTERED_TV_REQUESTS = [
|
||||||
|
{ id: 3, title: 'Superman Show', requestedDate: '2026-05-15T10:00:00.000Z', available: false, approved: false, denied: false, requested: true, theMovieDbId: 101 }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('GET /api/ombi/requests query params', () => {
|
||||||
|
let app;
|
||||||
|
let arrRetrieverRegistry;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = makeApp();
|
||||||
|
setupOmbiRequestMocks(FILTERED_MOVIE_REQUESTS, FILTERED_TV_REQUESTS);
|
||||||
|
|
||||||
|
// Reset the singleton registry so it re-initializes on each request
|
||||||
|
arrRetrieverRegistry = require('../../server/utils/arrRetrievers');
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (arrRetrieverRegistry) {
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by type=movie', async () => {
|
||||||
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/ombi/requests?type=movie&showAll=true')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.requests.movie.length, `Body was: ${JSON.stringify(res.body)}`).toBe(2);
|
||||||
|
expect(res.body.requests.tv).toHaveLength(0);
|
||||||
|
expect(res.body.total).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by type=tv', async () => {
|
||||||
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/ombi/requests?type=tv&showAll=true')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.requests.movie).toHaveLength(0);
|
||||||
|
expect(res.body.requests.tv).toHaveLength(1);
|
||||||
|
expect(res.body.total).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by status=pending', async () => {
|
||||||
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/ombi/requests?status=pending&showAll=true')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.total).toBe(1);
|
||||||
|
expect(res.body.requests.tv[0].title).toBe('Superman Show');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by status=available', async () => {
|
||||||
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/ombi/requests?status=available&showAll=true')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.total).toBe(1);
|
||||||
|
expect(res.body.requests.movie[0].title).toBe('Batman Returns');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by title_asc', async () => {
|
||||||
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/ombi/requests?sort=title_asc&showAll=true')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const all = [...res.body.requests.movie, ...res.body.requests.tv];
|
||||||
|
expect(all.map(r => r.title)).toEqual(['Batman Returns', 'The Batman', 'Superman Show']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by title_desc', async () => {
|
||||||
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/ombi/requests?sort=title_desc&showAll=true')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const all = [...res.body.requests.movie, ...res.body.requests.tv];
|
||||||
|
expect(all.map(r => r.title)).toEqual(['The Batman', 'Batman Returns', 'Superman Show']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by requestedDate_desc (default)', async () => {
|
||||||
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/ombi/requests?showAll=true')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const all = [...res.body.requests.movie, ...res.body.requests.tv];
|
||||||
|
expect(all.map(r => r.title)).toEqual(['The Batman', 'Batman Returns', 'Superman Show']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searches by title substring', async () => {
|
||||||
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/ombi/requests?search=bat&showAll=true')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.total).toBe(2);
|
||||||
|
expect(res.body.requests.movie).toHaveLength(2);
|
||||||
|
expect(res.body.requests.tv).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines multiple query params', async () => {
|
||||||
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/ombi/requests?type=movie&status=approved&search=bat&sort=title_asc&showAll=true')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.total).toBe(1);
|
||||||
|
expect(res.body.requests.movie[0].title).toBe('The Batman');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalid sort falls back to default', async () => {
|
||||||
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/ombi/requests?sort=invalid&showAll=true')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(res.body.total).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /api/ombi/webhook/status
|
// GET /api/ombi/webhook/status
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
getRequestStatus,
|
||||||
|
filterByType,
|
||||||
|
filterByStatus,
|
||||||
|
filterBySearch,
|
||||||
|
sortRequests,
|
||||||
|
applyRequestFilters
|
||||||
|
} = require('../../server/utils/ombiFilters');
|
||||||
|
|
||||||
|
function makeRequest(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
title: 'Test Request',
|
||||||
|
requestedDate: '2026-05-21T10:00:00.000Z',
|
||||||
|
available: false,
|
||||||
|
approved: false,
|
||||||
|
denied: false,
|
||||||
|
requested: true,
|
||||||
|
mediaType: 'movie',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// getRequestStatus
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('getRequestStatus', () => {
|
||||||
|
it('returns available when available is true', () => {
|
||||||
|
expect(getRequestStatus(makeRequest({ available: true }))).toBe('available');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns denied when denied is true', () => {
|
||||||
|
expect(getRequestStatus(makeRequest({ denied: true }))).toBe('denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns approved when approved is true', () => {
|
||||||
|
expect(getRequestStatus(makeRequest({ approved: true }))).toBe('approved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns pending when requested is true', () => {
|
||||||
|
expect(getRequestStatus(makeRequest({ requested: true }))).toBe('pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown for empty object', () => {
|
||||||
|
expect(getRequestStatus({})).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown for null', () => {
|
||||||
|
expect(getRequestStatus(null)).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows priority: available > denied > approved > pending', () => {
|
||||||
|
expect(getRequestStatus(makeRequest({ available: true, denied: true }))).toBe('available');
|
||||||
|
expect(getRequestStatus(makeRequest({ denied: true, approved: true }))).toBe('denied');
|
||||||
|
expect(getRequestStatus(makeRequest({ approved: true, requested: true }))).toBe('approved');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// filterByType
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('filterByType', () => {
|
||||||
|
const movie = makeRequest({ mediaType: 'movie' });
|
||||||
|
const tv = makeRequest({ mediaType: 'tv', id: 2 });
|
||||||
|
|
||||||
|
it('returns all when types is empty', () => {
|
||||||
|
expect(filterByType([movie, tv], [])).toEqual([movie, tv]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all when types includes "all"', () => {
|
||||||
|
expect(filterByType([movie, tv], ['all'])).toEqual([movie, tv]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters to movies only', () => {
|
||||||
|
expect(filterByType([movie, tv], ['movie'])).toEqual([movie]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters to tv only', () => {
|
||||||
|
expect(filterByType([movie, tv], ['tv'])).toEqual([tv]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive', () => {
|
||||||
|
expect(filterByType([movie, tv], ['MOVIE'])).toEqual([movie]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty array', () => {
|
||||||
|
expect(filterByType([], ['movie'])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// filterByStatus
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('filterByStatus', () => {
|
||||||
|
const pending = makeRequest({ requested: true });
|
||||||
|
const approved = makeRequest({ approved: true, requested: true, id: 2 });
|
||||||
|
const available = makeRequest({ available: true, id: 3 });
|
||||||
|
|
||||||
|
it('returns all when statuses is empty', () => {
|
||||||
|
expect(filterByStatus([pending, approved], [])).toEqual([pending, approved]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by single status', () => {
|
||||||
|
expect(filterByStatus([pending, approved], ['approved'])).toEqual([approved]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by multiple statuses', () => {
|
||||||
|
expect(filterByStatus([pending, approved, available], ['pending', 'available'])).toEqual([pending, available]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case-insensitive', () => {
|
||||||
|
expect(filterByStatus([pending], ['PENDING'])).toEqual([pending]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty array', () => {
|
||||||
|
expect(filterByStatus([], ['pending'])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// filterBySearch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('filterBySearch', () => {
|
||||||
|
const batman = makeRequest({ title: 'The Batman' });
|
||||||
|
const superman = makeRequest({ title: 'Superman', id: 2 });
|
||||||
|
|
||||||
|
it('returns all when query is empty', () => {
|
||||||
|
expect(filterBySearch([batman, superman], '')).toEqual([batman, superman]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all when query is whitespace', () => {
|
||||||
|
expect(filterBySearch([batman, superman], ' ')).toEqual([batman, superman]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by case-insensitive substring', () => {
|
||||||
|
expect(filterBySearch([batman, superman], 'bat')).toEqual([batman]);
|
||||||
|
expect(filterBySearch([batman, superman], 'BAT')).toEqual([batman]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing title', () => {
|
||||||
|
expect(filterBySearch([makeRequest({ title: undefined })], 'test')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty array', () => {
|
||||||
|
expect(filterBySearch([], 'test')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// sortRequests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('sortRequests', () => {
|
||||||
|
const oldReq = makeRequest({ id: 1, title: 'Alpha', requestedDate: '2026-01-01T00:00:00.000Z' });
|
||||||
|
const midReq = makeRequest({ id: 2, title: 'Beta', requestedDate: '2026-05-01T00:00:00.000Z' });
|
||||||
|
const newReq = makeRequest({ id: 3, title: 'Charlie', requestedDate: '2026-10-01T00:00:00.000Z' });
|
||||||
|
|
||||||
|
it('sorts newest to oldest by default', () => {
|
||||||
|
const sorted = sortRequests([oldReq, newReq, midReq], 'requestedDate_desc');
|
||||||
|
expect(sorted.map(r => r.id)).toEqual([3, 2, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts oldest to newest', () => {
|
||||||
|
const sorted = sortRequests([oldReq, newReq, midReq], 'requestedDate_asc');
|
||||||
|
expect(sorted.map(r => r.id)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts A-Z', () => {
|
||||||
|
const sorted = sortRequests([midReq, oldReq, newReq], 'title_asc');
|
||||||
|
expect(sorted.map(r => r.title)).toEqual(['Alpha', 'Beta', 'Charlie']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts Z-A', () => {
|
||||||
|
const sorted = sortRequests([midReq, oldReq, newReq], 'title_desc');
|
||||||
|
expect(sorted.map(r => r.title)).toEqual(['Charlie', 'Beta', 'Alpha']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to requestedDate_desc for unknown sort mode', () => {
|
||||||
|
const sorted = sortRequests([oldReq, newReq], 'invalid');
|
||||||
|
expect(sorted.map(r => r.id)).toEqual([3, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing requestedDate by treating as epoch 0', () => {
|
||||||
|
const noDate = makeRequest({ id: 4, requestedDate: undefined });
|
||||||
|
const sorted = sortRequests([midReq, noDate], 'requestedDate_desc');
|
||||||
|
expect(sorted[0]).toBe(midReq);
|
||||||
|
expect(sorted[1]).toBe(noDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing title', () => {
|
||||||
|
const noTitle = makeRequest({ id: 4, title: undefined });
|
||||||
|
const withTitle = makeRequest({ id: 5, title: 'Zebra' });
|
||||||
|
const sorted = sortRequests([noTitle, withTitle], 'title_asc');
|
||||||
|
expect(sorted[0]).toBe(noTitle);
|
||||||
|
expect(sorted[1]).toBe(withTitle);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// applyRequestFilters
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('applyRequestFilters', () => {
|
||||||
|
const moviePending = makeRequest({ id: 1, title: 'The Batman', mediaType: 'movie', requested: true, approved: false });
|
||||||
|
const tvApproved = makeRequest({ id: 2, title: 'Superman Show', mediaType: 'tv', approved: true, requested: false });
|
||||||
|
const movieAvailable = makeRequest({ id: 3, title: 'Batman Returns', mediaType: 'movie', available: true });
|
||||||
|
|
||||||
|
it('applies all filters together', () => {
|
||||||
|
const result = applyRequestFilters(
|
||||||
|
[moviePending, tvApproved, movieAvailable],
|
||||||
|
{ types: ['movie'], statuses: ['pending', 'available'], sort: 'title_asc', search: 'bat' }
|
||||||
|
);
|
||||||
|
expect(result.map(r => r.id)).toEqual([3, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unfiltered when no options provided', () => {
|
||||||
|
const result = applyRequestFilters([moviePending, tvApproved], {});
|
||||||
|
expect(result).toEqual([moviePending, tvApproved]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no matches', () => {
|
||||||
|
const result = applyRequestFilters(
|
||||||
|
[moviePending],
|
||||||
|
{ types: ['tv'] }
|
||||||
|
);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user