diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cb44f42..e345c6e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -62,7 +62,7 @@ flowchart TB auth_r["Auth Routes\n/api/auth"] dash_r["Dashboard Routes\n/api/dashboard (SSE /stream)"] 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"] ombi_r["Ombi Routes\n/api/ombi"] hist_r["History Routes\n/api/history"] proxy_r["Proxy Routes\n/api/sonarr · /api/radarr\n/api/sabnzbd · /api/emby"] @@ -119,7 +119,7 @@ flowchart TB Browser (SPA) │ POST /api/auth/login → Auth routes → Emby verify → set httpOnly cookie │ GET /api/dashboard/stream → SSE stream → cache → matched downloads - │ POST /api/webhook/* ← Sonarr/Radarr push events + │ POST /api/webhook/* ← Sonarr/Radarr/Ombi push events │ ▼ Express Server (:3001) @@ -132,10 +132,12 @@ Express Server (:3001) ├── /api/auth → login, logout, me, csrf ├── /api/webhook → [rate-limit] → [secret validation] → [payload validation] │ → [replay check] → updateWebhookMetrics → processWebhookEvent + │ → /config: GET endpoint for configuration status validation ├── /api/dashboard → requireAuth → read cache → DownloadBuilder → SSE/JSON ├── /api/status → requireAuth → admin cache/polling/webhook status ├── /api/history → requireAuth → historyFetcher (5 min cache) → filter + dedup ├── /api/ombi → requireAuth → PALDRA → filter/sort/search → JSON + │ → /webhook/*: enable (POST), status (GET), and test (POST) endpoints ├── /api/sonarr|radarr → requireAuth → verifyCsrf → proxy to *arr API └── /api/sabnzbd|emby → requireAuth → verifyCsrf → proxy @@ -380,11 +382,12 @@ Unmatched torrents are **not** included in the response (fixed in develop-refact ### 4.1 Webhook Receiver -sofarr exposes two webhook endpoints that Sonarr and Radarr can be configured to call on every automation event: +sofarr exposes three webhook endpoints that Sonarr, Radarr, and Ombi can be configured to call on automation and request events: ``` POST /api/webhook/sonarr POST /api/webhook/radarr +POST /api/webhook/ombi ``` Both endpoints share identical processing logic: @@ -468,6 +471,8 @@ The dashboard therefore receives fresh data within the round-trip time of the *a The `sonarr.js` and `radarr.js` route modules expose endpoints (under `/api/sonarr` and `/api/radarr` respectively, behind `requireAuth` + `verifyCsrf`) for one-click webhook configuration. These proxy to the *arr notification API to create, update, or remove the sofarr webhook connection in Sonarr/Radarr, using `SOFARR_BASE_URL` to construct the target URL. +Similarly, the `ombi.js` route module exposes endpoints under `/api/ombi/webhook/` (including `/enable`, `/status`, and `/test`) to support one-click registration and validation of the Sofarr webhook inside the configured Ombi instance. + --- ## 5. Data Flow and Real-time Updates diff --git a/README.md b/README.md index c15cc42..c464630 100644 --- a/README.md +++ b/README.md @@ -320,17 +320,16 @@ OMBI_URL=https://ombi.example.com OMBI_API_KEY=your-ombi-api-key ``` -**How it works:** -- Downloads are matched to Ombi requests using external IDs (TMDB, TVDB, IMDB) -- When a matching request is found, an Ombi icon appears in the download card -- Clicking the icon opens the Ombi request page -- If no request exists, a search link is provided instead -- Integration is fully optional - sofarr works perfectly without Ombi configured - -**External ID Matching:** -- **TV Shows**: TVDB ID (primary) → TMDB ID (fallback) -- **Movies**: TMDB ID (primary) → IMDB ID (fallback) -- Matching is performed automatically using data from Sonarr/Radarr +**Features & Architecture:** +- **Request Filtering & Search**: Retrieve, search, and filter requests in real-time by status (pending, approved, available, denied), media type (movies, TV shows), or name. +- **Dual-Layer Filtering & Persistence**: search and core filters are handled server-side for speed and efficiency, while responsive client-side refinements handle on-the-fly rendering. User filter preferences are persisted locally/session-wide to ensure a consistent experience across page refreshes. +- **Real-Time SSE Updates**: Integrates with the Server-Sent Events (SSE) push stream. When requests are created or updated, the dashboard is instantly notified without client-side polling. +- **External ID Matching**: Downloads are matched to Ombi requests using external IDs (TMDB, TVDB, IMDB). + - **TV Shows**: TVDB ID (primary) → TMDB ID (fallback) + - **Movies**: TMDB ID (primary) → IMDB ID (fallback) + - Matching is performed automatically using data from Sonarr/Radarr. +- **Interactive UI**: When a matching request is found, an Ombi icon appears in the download card to open the request page. If no request exists, a search link is provided instead. +- **Fully Optional**: Integration is fully optional — sofarr works perfectly without Ombi configured. ## Setting Up User Tags @@ -445,8 +444,10 @@ The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/e ### Webhook Receiver (no user auth — protected by `X-Sofarr-Webhook-Secret`) - `POST /api/webhook/sonarr` — receive Sonarr webhook events - `POST /api/webhook/radarr` — receive Radarr webhook events +- `POST /api/webhook/ombi` — receive Ombi webhook events ### Webhook Management (requires auth + CSRF) +- `GET /api/webhook/config` — get webhook configuration status - `GET /api/sonarr/api/v3/notification` — list Sonarr notification connections - `POST /api/sonarr/api/v3/notification` — create/update Sonarr notification connection - `GET /api/radarr/api/v3/notification` — list Radarr notification connections @@ -455,6 +456,12 @@ The proxy routes (`/api/sonarr/**`, `/api/radarr/**`, `/api/sabnzbd/**`, `/api/e - `POST /api/radarr/webhook/enable` — one-click enable Sofarr webhook in Radarr - `POST /api/sonarr/webhook/test` — trigger a Sonarr test event - `POST /api/radarr/webhook/test` — trigger a Radarr test event +- `GET /api/ombi/webhook/status` — get Ombi webhook status and metrics +- `POST /api/ombi/webhook/enable` — one-click enable Sofarr webhook in Ombi +- `POST /api/ombi/webhook/test` — trigger an Ombi test event + +### Ombi (requires auth) +- `GET /api/ombi/requests` — get Ombi requests with server-side filtering, search, and sorting ### Service APIs (proxy to your services) - `GET /api/sabnzbd/*` — SABnzbd API proxy @@ -499,7 +506,7 @@ npm run test:coverage # with V8 coverage report (outputs to coverage/) npm run test:ui # interactive Vitest UI ``` -290 tests across 18 test files covering auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, history deduplication/classification, download client architecture, webhook endpoint security, input validation, replay protection, and webhook metrics integration. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets. +Over 830 tests across 39 files covering auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, history deduplication/classification, download client architecture, webhook endpoint security, input validation, replay protection, webhook metrics integration, polling retrievers, and Ombi filter/search logic. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets. ## Development diff --git a/server/openapi.yaml b/server/openapi.yaml index a43ab77..cc9c8b7 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -276,7 +276,6 @@ components: - arrQueueId - arrType - arrInstanceUrl - - arrInstanceKey - arrContentId - arrContentType properties: @@ -296,7 +295,7 @@ components: example: "http://sonarr:8989" arrInstanceKey: type: string - description: API key for the *arr instance + description: API key for the *arr instance. Only required for admin users; non-admin requests resolve the key from server-side configurations using arrInstanceUrl. example: "abc123def456" arrContentId: type: integer @@ -686,7 +685,7 @@ paths: post: tags: [Dashboard] summary: Blocklist and re-search - description: Admin-only. Removes queue item with blocklist=true, then triggers new automatic search. + description: Removes queue item with blocklist=true, then triggers new automatic search. Accessible by admins, or by non-admins who own the item under specific eligibility conditions (has import issues, or torrent older than 1h and availability < 100%). security: - CookieAuth: [] - CsrfToken: [] @@ -708,7 +707,7 @@ paths: type: boolean example: true '403': - description: Admin access required + description: Permission denied (admin or qualifying conditions required) content: application/json: schema: @@ -853,6 +852,72 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + /api/webhook/ombi: + post: + tags: [Webhook] + summary: Ombi webhook + description: Receives webhook events from Ombi. Requires X-Sofarr-Webhook-Secret header. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Event received + content: + application/json: + schema: + type: object + properties: + received: + type: boolean + example: true + '401': + description: Invalid or missing secret + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '400': + description: Invalid payload + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/webhook/config: + get: + tags: [Webhook] + summary: Get webhook configuration status + description: Returns whether the required webhook configuration is properly configured. + security: + - CookieAuth: [] + responses: + '200': + description: Webhook configuration status + content: + application/json: + schema: + type: object + properties: + valid: + type: boolean + example: true + missing: + type: array + items: + type: string + example: [] + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + # Sonarr proxy endpoints (detailed in JSDoc) /api/sonarr/queue: get: @@ -1478,3 +1543,203 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + + # Ombi endpoints + /api/ombi/requests: + get: + tags: [Ombi] + summary: Get Ombi requests + description: Returns Ombi movie and TV requests. Non-admin users only see their own requests, while admins see all requests. Supports server-side filtering by media type, request status, title search, and sorting. + security: + - CookieAuth: [] + parameters: + - name: type + in: query + schema: + type: array + items: + type: string + enum: [movie, tv, all] + default: [all] + description: Filter by media type. Omit or use `all` for both. + style: form + explode: true + - name: status + in: query + schema: + type: array + items: + type: string + enum: [pending, approved, available, denied] + description: Filter by request status. Omit for all statuses. + style: form + explode: true + - name: sort + in: query + schema: + type: string + enum: [requestedDate_desc, requestedDate_asc, title_asc, title_desc] + default: requestedDate_desc + description: Sort mode. + - name: search + in: query + schema: + type: string + description: Case-insensitive substring match on title. + - name: showAll + in: query + schema: + type: string + enum: ['true', 'false'] + description: Admin only. Show all users' requests. + responses: + '200': + description: Ombi requests retrieved successfully + content: + application/json: + schema: + type: object + properties: + user: + type: string + isAdmin: + type: boolean + showAll: + type: boolean + requests: + type: object + properties: + movie: + type: array + items: + $ref: '#/components/schemas/OmbiRequest' + tv: + type: array + items: + $ref: '#/components/schemas/OmbiRequest' + total: + type: integer + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/ombi/webhook/enable: + post: + tags: [Ombi] + summary: Enable Ombi webhook + description: Registers or updates the Sofarr webhook in Ombi. Requires authentication and CSRF protection. + security: + - CookieAuth: [] + responses: + '200': + description: Webhook enabled successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + webhookUrl: + type: string + applicationToken: + type: string + '400': + description: Invalid request or missing configuration + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/ombi/webhook/status: + get: + tags: [Ombi] + summary: Get Ombi webhook status + description: Returns the current Ombi webhook configuration status and metrics. + security: + - CookieAuth: [] + responses: + '200': + description: Webhook status retrieved successfully + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + webhookUrl: + type: string + nullable: true + applicationToken: + type: string + nullable: true + triggers: + type: object + properties: + requestAvailable: + type: boolean + requestApproved: + type: boolean + requestDeclined: + type: boolean + requestPending: + type: boolean + requestProcessing: + type: boolean + stats: + type: object + nullable: true + properties: + eventsReceived: + type: integer + pollsSkipped: + type: integer + lastWebhookTimestamp: + type: integer + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/ombi/webhook/test: + post: + tags: [Ombi] + summary: Test Ombi webhook + description: Sends a test webhook event to the Sofarr Ombi webhook endpoint. + security: + - CookieAuth: [] + - CsrfToken: [] + responses: + '200': + description: Test webhook sent successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + '400': + description: Invalid request or missing configuration + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 4891461..6e430b2 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -558,15 +558,18 @@ router.get('/stream', requireAuth, async (req, res) => { * tags: [Dashboard] * summary: Blocklist and re-search * description: | - * Admin-only endpoint that removes a queue item from Sonarr/Radarr with blocklist=true - * (so the release is not grabbed again), then immediately triggers a new automatic search - * for the same episode/movie. + * Removes a queue item from Sonarr/Radarr with blocklist=true (so the release is not grabbed again), + * then immediately triggers a new automatic search for the same episode/movie. * - * **Authentication:** Requires valid `emby_user` cookie (admin only) and `X-CSRF-Token` header. + * Accessible by admins, or by non-admins who own the item under specific qualifying eligibility conditions: + * - The download has import issues OR + * - The torrent is older than 1 hour and has availability below 100% + * + * **Authentication:** Requires valid `emby_user` cookie and `X-CSRF-Token` header. * * **Workflow:** - * 1. Validate user is admin - * 2. Validate all required fields are present + * 1. Validate user and required fields + * 2. Check blocklist eligibility (admin status or non-admin qualifying criteria) * 3. Delete queue item from Sonarr/Radarr with `removeFromClient=true` and `blocklist=true` * 4. Trigger automatic search command: * - Sonarr: EpisodeSearch with episodeIds @@ -577,18 +580,17 @@ router.get('/stream', requireAuth, async (req, res) => { * - `arrQueueId`: Sonarr/Radarr queue record ID * - `arrType`: Must be "sonarr" or "radarr" * - `arrInstanceUrl`: Base URL of the *arr instance - * - `arrInstanceKey`: API key for the *arr instance + * - `arrInstanceKey`: API key for the *arr instance (only required for admins; non-admins resolve via server config) * - `arrContentId`: episodeId (Sonarr) or movieId (Radarr) * - `arrContentType`: Must be "episode" (Sonarr) or "movie" (Radarr) * * **Error Responses:** - * - 403: Non-admin user attempts access + * - 403: User lacks permissions (admin or qualifying conditions required) * - 400: Missing required fields or invalid arrType * - 502: Failed to communicate with *arr instance * - * **x-integration-notes:** This endpoint is used from the dashboard UI when an admin - * clicks "Blocklist + Re-search" on a failed download. The arr instance credentials - * are passed from the download object (which includes them for admin users). + * **x-integration-notes:** This endpoint is used from the dashboard UI when a qualified user or admin + * clicks "Blocklist + Re-search" on a stalled or failed download. * security: * - CookieAuth: [] * - CsrfToken: [] @@ -627,13 +629,13 @@ router.get('/stream', requireAuth, async (req, res) => { * example: * error: "Missing required fields" * '403': - * description: Admin access required + * description: Permission denied (admin or qualifying conditions required) * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * example: - * error: "Admin access required" + * error: "Permission denied: admin or qualifying conditions required" * '502': * description: Failed to communicate with *arr instance * content: diff --git a/tests/integration/swagger-coverage.test.js b/tests/integration/swagger-coverage.test.js index 982d3df..dafc5b9 100644 --- a/tests/integration/swagger-coverage.test.js +++ b/tests/integration/swagger-coverage.test.js @@ -142,6 +142,10 @@ describe('Swagger Coverage', () => { expect(paths['/api/webhook/sonarr'].post).toBeDefined(); expect(paths['/api/webhook/radarr']).toBeDefined(); expect(paths['/api/webhook/radarr'].post).toBeDefined(); + expect(paths['/api/webhook/ombi']).toBeDefined(); + expect(paths['/api/webhook/ombi'].post).toBeDefined(); + expect(paths['/api/webhook/config']).toBeDefined(); + expect(paths['/api/webhook/config'].get).toBeDefined(); }); it('should have Sonarr proxy endpoints documented', () => { @@ -179,6 +183,19 @@ describe('Swagger Coverage', () => { expect(paths['/api/emby/session/{sessionId}/user']).toBeDefined(); }); + it('should have Ombi endpoints documented', () => { + const paths = openapiSpec.paths; + + expect(paths['/api/ombi/requests']).toBeDefined(); + expect(paths['/api/ombi/requests'].get).toBeDefined(); + expect(paths['/api/ombi/webhook/enable']).toBeDefined(); + expect(paths['/api/ombi/webhook/enable'].post).toBeDefined(); + expect(paths['/api/ombi/webhook/status']).toBeDefined(); + expect(paths['/api/ombi/webhook/status'].get).toBeDefined(); + expect(paths['/api/ombi/webhook/test']).toBeDefined(); + expect(paths['/api/ombi/webhook/test'].post).toBeDefined(); + }); + it('should return 200 for Swagger UI endpoint', async () => { const response = await request(app).get('/api/swagger').redirects(1); expect(response.status).toBe(200);