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 };
|
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) => {
|
router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = req.user;
|
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.
|
* @openapi
|
||||||
// Requires authentication. Only proxies http/https URLs.
|
* /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) => {
|
router.get('/cover-art', requireAuth, async (req, res) => {
|
||||||
const { url } = req.query;
|
const { url } = req.query;
|
||||||
if (!url || typeof url !== 'string') {
|
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).
|
* @openapi
|
||||||
// Auth is enforced by requireAuth (emby_user cookie sent with the upgrade request).
|
* /api/dashboard/stream:
|
||||||
// No CSRF token needed — SSE is a GET request (safe method, no state change).
|
* 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) => {
|
router.get('/stream', requireAuth, async (req, res) => {
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
const username = user.name.toLowerCase();
|
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
|
* **Authentication:** Requires valid `emby_user` cookie (admin only) and `X-CSRF-Token` header.
|
||||||
* (so the release is not grabbed again), then immediately triggers a new
|
|
||||||
* automatic search for the same episode/movie.
|
|
||||||
*
|
*
|
||||||
* Body: {
|
* **Workflow:**
|
||||||
* arrQueueId: number — Sonarr/Radarr queue record id
|
* 1. Validate user is admin
|
||||||
* arrType: 'sonarr'|'radarr'
|
* 2. Validate all required fields are present
|
||||||
* arrInstanceUrl: string — base URL of the arr instance
|
* 3. Delete queue item from Sonarr/Radarr with `removeFromClient=true` and `blocklist=true`
|
||||||
* arrInstanceKey: string — API key for the arr instance
|
* 4. Trigger automatic search command:
|
||||||
* arrContentId: number — episodeId (Sonarr) or movieId (Radarr)
|
* - Sonarr: EpisodeSearch with episodeIds
|
||||||
* arrContentType: 'episode'|'movie'
|
* - 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) => {
|
router.post('/blocklist-search', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user