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:
+405
-21
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user