docs(swagger): add JSDoc @openapi for dashboard endpoints

- GET /api/dashboard/user-downloads: deprecated, use SSE
- GET /api/dashboard/cover-art: image proxy for CSP compliance
- GET /api/dashboard/stream: SSE real-time updates, no CSRF needed
- POST /api/dashboard/blocklist-search: admin-only, removes + re-searches
- Document SSE event format and heartbeat
- Include admin-only constraints and error responses
This commit is contained in:
2026-05-21 12:32:29 +01:00
parent 1bb9e4014e
commit 12effe17d3
+405 -21
View File
@@ -62,8 +62,95 @@ function buildMetadataMaps(snapshot) {
return { seriesMap, moviesMap, sonarrTagMap, radarrTagMap };
}
// Get user downloads for authenticated user
// DEPRECATED: Use /stream endpoint for real-time updates
/**
* @openapi
* /api/dashboard/user-downloads:
* get:
* tags: [Dashboard]
* summary: Get user downloads (deprecated)
* description: |
* **DEPRECATED:** Use GET /api/dashboard/stream for real-time updates via Server-Sent Events.
*
* Returns current download data for the authenticated user. This endpoint fetches
* data from cache or triggers a fresh poll if polling is disabled and cache is empty.
*
* **Authentication:** Requires valid `emby_user` cookie.
*
* **Filtering:**
* - Non-admin users: Only see downloads tagged with their username
* - Admin users: Can see all downloads by setting query parameter `showAll=true`
*
* **Data Sources:** Aggregates data from SABnzbd, qBittorrent, Transmission, rTorrent,
* Sonarr, and Radarr, matching downloads to series/movie metadata.
*
* **x-integration-notes:** This endpoint returns a snapshot. For real-time updates,
* use the SSE stream at /api/dashboard/stream instead.
* security:
* - CookieAuth: []
* parameters:
* - name: showAll
* in: query
* schema:
* type: boolean
* default: false
* description: Admin-only: show all users' downloads
* responses:
* '200':
* description: User downloads
* content:
* application/json:
* schema:
* type: object
* properties:
* user:
* type: string
* example: "john"
* isAdmin:
* type: boolean
* example: false
* downloads:
* type: array
* items:
* $ref: '#/components/schemas/NormalizedDownload'
* example:
* user: "john"
* isAdmin: false
* downloads:
* - id: "abc123"
* title: "Show.Name.S01E01.1080p.WEB-DL"
* type: "torrent"
* client: "qbittorrent"
* status: "Downloading"
* progress: 45.5
* size: 1073741824
* downloaded: 536870912
* '401':
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* '500':
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* x-code-samples:
* - lang: curl
* label: cURL
* source: |
* curl -X GET "http://localhost:3001/api/dashboard/user-downloads" \
* -b cookies.txt
* - lang: JavaScript
* label: JavaScript (fetch)
* source: |
* const response = await fetch('http://localhost:3001/api/dashboard/user-downloads', {
* method: 'GET',
* credentials: 'include'
* });
* const data = await response.json();
*/
router.get('/user-downloads', requireAuth, async (req, res) => {
try {
const user = req.user;
@@ -103,9 +190,91 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
}
});
// Cover art proxy — fetches external poster images server-side so the
// browser loads them from 'self' and the CSP img-src stays tight.
// Requires authentication. Only proxies http/https URLs.
/**
* @openapi
* /api/dashboard/cover-art:
* get:
* tags: [Dashboard]
* summary: Cover art proxy
* description: |
* Proxies external poster images server-side so the browser loads them from 'self'
* and the Content Security Policy (CSP) img-src directive stays tight.
*
* **Authentication:** Requires valid `emby_user` cookie.
*
* **Purpose:** Sonarr/Radarr return image URLs from external domains. To maintain
* a strict CSP (img-src: 'self'), this endpoint fetches the image server-side and
* serves it as if it originated from the sofarr domain.
*
* **Constraints:**
* - URL must be http:// or https://
* - Content type must be an image (image/*)
* - Maximum image size: 5 MB
* - Timeout: 8 seconds
* - Browser cache: 24 hours (public, max-age=86400)
*
* **Error Responses:**
* - 400: Missing URL, invalid URL, or non-image content type
* - 502: Failed to fetch from remote server
* security:
* - CookieAuth: []
* parameters:
* - name: url
* in: query
* required: true
* schema:
* type: string
* format: uri
* description: External image URL to proxy
* example: "http://sonarr:8989/media/poster.jpg"
* responses:
* '200':
* description: Image data
* content:
* image/*:
* headers:
* Content-Type:
* description: Image content type from remote server
* schema:
* type: string
* Cache-Control:
* description: Cache directive (24 hours)
* schema:
* type: string
* example: "public, max-age=86400"
* '400':
* description: Invalid URL or non-image
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* examples:
* missingUrl:
* error: "Missing url parameter"
* invalidUrl:
* error: "Invalid url"
* notImage:
* error: "Remote URL is not an image"
* '502':
* description: Failed to fetch from remote
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* error: "Failed to fetch cover art"
* x-code-samples:
* - lang: curl
* label: cURL
* source: |
* curl -X GET "http://localhost:3001/api/dashboard/cover-art?url=http://sonarr:8989/media/poster.jpg" \
* -b cookies.txt \
* --output poster.jpg
* - lang: HTML
* label: HTML img tag
* source: |
* <img src="/api/dashboard/cover-art?url=http://sonarr:8989/media/poster.jpg" alt="Poster" />
*/
router.get('/cover-art', requireAuth, async (req, res) => {
const { url } = req.query;
if (!url || typeof url !== 'string') {
@@ -140,10 +309,121 @@ router.get('/cover-art', requireAuth, async (req, res) => {
}
});
// SSE stream — pushes download data to the client on every poll cycle.
// Uses the browser's built-in EventSource API (no library required).
// Auth is enforced by requireAuth (emby_user cookie sent with the upgrade request).
// No CSRF token needed — SSE is a GET request (safe method, no state change).
/**
* @openapi
* /api/dashboard/stream:
* get:
* tags: [Dashboard]
* summary: SSE stream for real-time updates
* description: |
* Server-Sent Events (SSE) stream that pushes download data to the client on every poll cycle.
* Uses the browser's built-in EventSource API (no library required).
*
* **Authentication:** Requires valid `emby_user` cookie. CSRF token NOT required (GET request).
*
* **SSE Event Format:**
* - Initial payload sent immediately on connection
* - Subsequent payloads sent after each poll cycle (or webhook-triggered refresh)
* - Each payload is a `data:` frame containing JSON with `user`, `isAdmin`, `downloads`, and `downloadClients`
* - Heartbeat comment (`: heartbeat`) sent every 25 seconds to keep connection alive
* - Optional `history-update` event when history is refreshed
*
* **Payload Structure:**
* ```json
* {
* "user": "john",
* "isAdmin": false,
* "downloads": [...],
* "downloadClients": [
* { "id": "qbittorrent-main", "name": "Main qBittorrent", "type": "qbittorrent" }
* ]
* }
* ```
*
* **Filtering:**
* - Non-admin users: Only see downloads tagged with their username
* - Admin users: Can see all downloads by setting query parameter `showAll=true`
*
* **Connection Management:**
* - Server tracks active clients for cleanup and admin status panel
* - On client disconnect: deregisters callback, stops heartbeat, removes from active clients
* - Browser's EventSource API handles automatic reconnection on network interruption
*
* **Headers:**
* - Content-Type: text/event-stream
* - Cache-Control: no-cache, no-transform
* - Connection: keep-alive
* - X-Accel-Buffering: no (disables nginx proxy buffering)
*
* **x-integration-notes:** Use EventSource in browser:
* ```javascript
* const eventSource = new EventSource('/api/dashboard/stream', { withCredentials: true });
* eventSource.onmessage = (event) => {
* const data = JSON.parse(event.data);
* console.log('Downloads:', data.downloads);
* };
* ```
* security:
* - CookieAuth: []
* parameters:
* - name: showAll
* in: query
* schema:
* type: boolean
* default: false
* description: Admin-only: show all users' downloads
* responses:
* '200':
* description: SSE stream established
* content:
* text/event-stream:
* schema:
* type: string
* description: Server-Sent Events stream
* headers:
* Content-Type:
* schema:
* type: string
* example: "text/event-stream"
* Cache-Control:
* schema:
* type: string
* example: "no-cache, no-transform"
* Connection:
* schema:
* type: string
* example: "keep-alive"
* X-Accel-Buffering:
* schema:
* type: string
* example: "no"
* '401':
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* x-code-samples:
* - lang: JavaScript
* label: Browser EventSource
* source: |
* const eventSource = new EventSource('/api/dashboard/stream', {
* withCredentials: true
* });
* eventSource.onmessage = (event) => {
* const data = JSON.parse(event.data);
* console.log('User:', data.user);
* console.log('Downloads:', data.downloads.length);
* };
* eventSource.addEventListener('history-update', (event) => {
* const data = JSON.parse(event.data);
* console.log('History updated for:', data.type);
* });
* - lang: curl
* label: cURL (test SSE)
* source: |
* curl -N -H "Cookie: emby_user=..." http://localhost:3001/api/dashboard/stream
*/
router.get('/stream', requireAuth, async (req, res) => {
const user = req.user;
const username = user.name.toLowerCase();
@@ -230,20 +510,124 @@ router.get('/stream', requireAuth, async (req, res) => {
});
/**
* POST /api/dashboard/blocklist-search
* @openapi
* /api/dashboard/blocklist-search:
* post:
* 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.
*
* Admin-only. 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.
*
* Body: {
* arrQueueId: number — Sonarr/Radarr queue record id
* arrType: 'sonarr'|'radarr'
* arrInstanceUrl: string — base URL of the arr instance
* arrInstanceKey: string — API key for the arr instance
* arrContentId: number — episodeId (Sonarr) or movieId (Radarr)
* arrContentType: 'episode'|'movie'
* }
* **Workflow:**
* 1. Validate user is admin
* 2. Validate all required fields are present
* 3. Delete queue item from Sonarr/Radarr with `removeFromClient=true` and `blocklist=true`
* 4. Trigger automatic search command:
* - Sonarr: EpisodeSearch with episodeIds
* - Radarr: MoviesSearch with movieIds
* 5. Invalidate poll cache so next SSE push reflects the removed item
*
* **Required Fields:**
* - `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
* - `arrContentId`: episodeId (Sonarr) or movieId (Radarr)
* - `arrContentType`: Must be "episode" (Sonarr) or "movie" (Radarr)
*
* **Error Responses:**
* - 403: Non-admin user attempts access
* - 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).
* security:
* - CookieAuth: []
* - CsrfToken: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/BlocklistSearchRequest'
* example:
* arrQueueId: 123
* arrType: "sonarr"
* arrInstanceUrl: "http://sonarr:8989"
* arrInstanceKey: "abc123def456"
* arrContentId: 456
* arrContentType: "episode"
* responses:
* '200':
* description: Blocklist and search successful
* content:
* application/json:
* schema:
* type: object
* properties:
* ok:
* type: boolean
* example: true
* example:
* ok: true
* '400':
* description: Missing required fields or invalid arrType
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* error: "Missing required fields"
* '403':
* description: Admin access required
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* error: "Admin access required"
* '502':
* description: Failed to communicate with *arr instance
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* error: "Failed to blocklist and search"
* x-code-samples:
* - lang: JavaScript
* label: JavaScript (fetch)
* source: |
* const csrfResponse = await fetch('http://localhost:3001/api/auth/csrf', {
* method: 'GET',
* credentials: 'include'
* });
* const { csrfToken } = await csrfResponse.json();
*
* const response = await fetch('http://localhost:3001/api/dashboard/blocklist-search', {
* method: 'POST',
* headers: {
* 'Content-Type': 'application/json',
* 'X-CSRF-Token': csrfToken
* },
* credentials: 'include',
* body: JSON.stringify({
* arrQueueId: 123,
* arrType: 'sonarr',
* arrInstanceUrl: 'http://sonarr:8989',
* arrInstanceKey: 'abc123def456',
* arrContentId: 456,
* arrContentType: 'episode'
* })
* });
* const data = await response.json();
* console.log(data.ok); // true
*/
router.post('/blocklist-search', requireAuth, async (req, res) => {
try {