Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae9e877445 | |||
| 853b205c46 | |||
| 8c4cc20551 | |||
| da77f083fe | |||
| 71feaf0175 | |||
| 65b9f0f395 | |||
| b41f943407 | |||
| 9debd77392 | |||
| 20dfe06866 | |||
| a0f630fb81 | |||
| e640215502 | |||
| 972b407956 | |||
| cf7008fd54 | |||
| 2747ca7754 | |||
| 0341540751 | |||
| 3bb9e936c3 | |||
| aef21d1b50 | |||
| a6fcde58cf | |||
| d839fa98a0 | |||
| a92ab85bc0 | |||
| 57b127ea95 | |||
| 56f42755cc | |||
| 15152714fd | |||
| 19b9c97e64 | |||
| 55a5577f2a | |||
| 6139095444 | |||
| 4c9985e01a | |||
| fecb96b04e | |||
| c98b81c8bd | |||
| 90bf411e0c | |||
| 867e86615e | |||
| 2cbe3c6b76 | |||
| 59adcbc36e | |||
| 6865b860bc | |||
| 9aaff5c368 | |||
| ce6f9b0459 | |||
| 976d6527b6 | |||
| 6a8ca90fd3 | |||
| 2d5958006c | |||
| 9faf8c0ea3 |
@@ -7,16 +7,24 @@ on:
|
|||||||
- "package.json"
|
- "package.json"
|
||||||
- "package-lock.json"
|
- "package-lock.json"
|
||||||
- ".gitea/workflows/licence-check.yml"
|
- ".gitea/workflows/licence-check.yml"
|
||||||
|
- "**/*.js"
|
||||||
|
- "**/*.ts"
|
||||||
|
- "**/*.jsx"
|
||||||
|
- "**/*.tsx"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["**", "!main", "!release/**"]
|
branches: ["**", "!main", "!release/**"]
|
||||||
paths:
|
paths:
|
||||||
- "package.json"
|
- "package.json"
|
||||||
- "package-lock.json"
|
- "package-lock.json"
|
||||||
- ".gitea/workflows/licence-check.yml"
|
- ".gitea/workflows/licence-check.yml"
|
||||||
|
- "**/*.js"
|
||||||
|
- "**/*.ts"
|
||||||
|
- "**/*.jsx"
|
||||||
|
- "**/*.tsx"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
licence-check:
|
licence-check:
|
||||||
name: Dependency licence compatibility
|
name: Licence compatibility and copyright header verification
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
steps:
|
steps:
|
||||||
@@ -36,3 +44,40 @@ jobs:
|
|||||||
--onlyAllow "MIT;ISC;MIT-0;BSD-2-Clause;BSD-3-Clause;Apache-2.0;CC0-1.0;BlueOak-1.0.0" \
|
--onlyAllow "MIT;ISC;MIT-0;BSD-2-Clause;BSD-3-Clause;Apache-2.0;CC0-1.0;BlueOak-1.0.0" \
|
||||||
--excludePrivatePackages \
|
--excludePrivatePackages \
|
||||||
&& echo "All production dependency licences are compatible with MIT."
|
&& echo "All production dependency licences are compatible with MIT."
|
||||||
|
|
||||||
|
- name: Check copyright headers in source files
|
||||||
|
run: |
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Find all source files, excluding build artifacts and node_modules
|
||||||
|
SOURCE_FILES=$(find . -type f \( -name "*.js" -o -name "*.ts" -o -name "*.jsx" -o -name "*.tsx" \) \
|
||||||
|
! -path "./node_modules/*" \
|
||||||
|
! -path "./.git/*" \
|
||||||
|
! -path "./dist/*" \
|
||||||
|
! -path "./build/*" \
|
||||||
|
! -path "./.gitea/*")
|
||||||
|
|
||||||
|
MISSING_HEADER=0
|
||||||
|
|
||||||
|
# Check each file for MIT-compliant copyright header
|
||||||
|
while IFS= read -r file; do
|
||||||
|
if [ -z "$file" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if file starts with a copyright header containing: Copyright, year (4 digits), name, and MIT License
|
||||||
|
if ! head -n 5 "$file" | grep -qiE "Copyright.*[0-9]{4}.*MIT"; then
|
||||||
|
echo "❌ Missing MIT-compliant copyright header in: $file"
|
||||||
|
echo " Required format: // Copyright (c) YYYY Name. MIT License."
|
||||||
|
MISSING_HEADER=$((MISSING_HEADER + 1))
|
||||||
|
fi
|
||||||
|
done <<< "$SOURCE_FILES"
|
||||||
|
|
||||||
|
if [ $MISSING_HEADER -gt 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ Found $MISSING_HEADER file(s) with missing or non-compliant copyright headers."
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✅ All source files have MIT-compliant copyright headers."
|
||||||
|
fi
|
||||||
|
|||||||
@@ -6,6 +6,46 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-05-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **History tab** — new "Recently Completed" tab showing imported and failed downloads from Sonarr/Radarr history for the last N days (configurable via the days input, persisted in `localStorage`). Auto-refreshes every 5 minutes.
|
||||||
|
- **History deduplication** — when a failed download has subsequently been imported successfully, only the successful record is shown. If the most recent record for an item is a failure but the episode/movie is already on disk (upgrade attempt), the record is flagged as `availableForUpgrade`.
|
||||||
|
- **"Upgrade available" badge** — failed history cards where the content is already on disk display an amber badge to indicate this is a failed upgrade rather than a missing item.
|
||||||
|
- **"Hide upgrade failures" toggle** — checkbox in the history tab to filter out failed records that are already available on disk. State persists in `localStorage`. Tooltip explains the behaviour and matches the episode/multi-episode tooltip style.
|
||||||
|
- **Blocklist & Search button** — admin-only button on download cards with an "Import Pending" caution. Removes the download from the client with `blocklist=true` (preventing re-grab of the same release) then immediately triggers an `EpisodeSearch`/`MoviesSearch` command in Sonarr/Radarr. Shows a confirmation dialog, loading/success/error states. Kicks a background poll on success.
|
||||||
|
- **`POST /api/dashboard/blocklist-search`** — new admin-only endpoint backing the above button. Accepts `arrQueueId`, `arrType`, `arrInstanceUrl`, `arrInstanceKey`, `arrContentId`, `arrContentType`.
|
||||||
|
- **Title link home navigation** — the sofarr logo/title in the header now navigates to the default view (Active Downloads tab, close status panel, reset "Show all users" toggle) without a page reload.
|
||||||
|
- **Version footer link** — the version string in the dashboard footer links to the source repository.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- History records are now deduplicated server-side before being sent to the client — only the most relevant record per content item per instance is returned.
|
||||||
|
- Import-issue badge tooltip now uses the themed `var(--surface)` / `var(--text-primary)` / `var(--border)` CSS variables, matching the episode and toggle tooltip style.
|
||||||
|
- Poller now stores `_instanceKey` on Sonarr/Radarr queue records in the cache, enabling the backend to look up API credentials for blocklist operations without an additional configuration lookup.
|
||||||
|
- **Blocklist & Search button** — now available on all admin downloads (not just those with import issues), and also available to non-admin users when: import issues are present, OR (for qBittorrent torrents only) the download is more than 1 hour old AND has less than 100% availability.
|
||||||
|
- **Download object** — added `canBlocklist` boolean field to indicate whether the current user can blocklist a given download.
|
||||||
|
- **qBittorrent torrent data** — added `addedOn` timestamp field to enable age-based blocklist eligibility checks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.2.2] - 2026-05-17
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Header logo** — uses the higher-resolution 192px favicon source rendered at 56px for better visual balance alongside the title text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.2.1] - 2026-05-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Version footer** — the dashboard footer now displays the running app version (e.g. `sofarr v1.2.1`), fetched from the `/health` endpoint on page load.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.2.0] - 2025-05-17
|
## [1.2.0] - 2025-05-17
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
sofarr connects to your media stack and shows you a personalized view of:
|
sofarr connects to your media stack and shows you a personalized view of:
|
||||||
- **Active Downloads** - See what's currently downloading from Usenet (SABnzbd) and BitTorrent (qBittorrent)
|
- **Active Downloads** - See what's currently downloading from Usenet (SABnzbd) and BitTorrent (qBittorrent)
|
||||||
- **Progress Tracking** - Real-time progress bars with speed, ETA, and completion estimates
|
- **Progress Tracking** - Real-time progress bars with speed, ETA, and completion estimates
|
||||||
|
- **Recently Completed** - History tab showing imported and failed downloads from Sonarr/Radarr with deduplication and upgrade-awareness
|
||||||
- **User Matching** - Downloads are matched to you based on tags in Sonarr/Radarr
|
- **User Matching** - Downloads are matched to you based on tags in Sonarr/Radarr
|
||||||
- **Multi-Instance Support** - Connect to multiple instances of each service
|
- **Multi-Instance Support** - Connect to multiple instances of each service
|
||||||
|
|
||||||
@@ -279,6 +280,10 @@ sofarr polls all configured services in the background and caches the results. D
|
|||||||
- `GET /api/dashboard/user-summary` — Per-user download counts (admin)
|
- `GET /api/dashboard/user-summary` — Per-user download counts (admin)
|
||||||
- `GET /api/dashboard/status` — Server / polling / cache status (admin)
|
- `GET /api/dashboard/status` — Server / polling / cache status (admin)
|
||||||
- `GET /api/dashboard/cover-art` — Proxied cover art image
|
- `GET /api/dashboard/cover-art` — Proxied cover art image
|
||||||
|
- `POST /api/dashboard/blocklist-search` — Blocklist a release and trigger a new search (admin or non-admin with eligibility: import issues OR torrent >1h old AND availability<100%)
|
||||||
|
|
||||||
|
### History
|
||||||
|
- `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history
|
||||||
|
|
||||||
### Service APIs (proxy to your services)
|
### Service APIs (proxy to your services)
|
||||||
- `GET /api/sabnzbd/*` — SABnzbd API proxy
|
- `GET /api/sabnzbd/*` — SABnzbd API proxy
|
||||||
@@ -323,7 +328,7 @@ npm run test:coverage # with V8 coverage report (outputs to coverage/)
|
|||||||
npm run test:ui # interactive Vitest UI
|
npm run test:ui # interactive Vitest UI
|
||||||
```
|
```
|
||||||
|
|
||||||
115 tests across 8 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, and qBittorrent utilities. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
|
145 tests across 10 test files covering the security-critical paths: auth middleware, CSRF protection, secret sanitization, config parsing, token store, qBittorrent utilities, and history deduplication/classification. See [`tests/README.md`](tests/README.md) for design decisions and coverage targets.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,6 @@ sofarr/
|
|||||||
│ └── integration/ # Supertest integration tests (nock for external HTTP)
|
│ └── integration/ # Supertest integration tests (nock for external HTTP)
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── ARCHITECTURE.md # This document
|
│ ├── ARCHITECTURE.md # This document
|
||||||
│ └── diagrams/ # PlantUML source files
|
|
||||||
├── .gitea/workflows/
|
├── .gitea/workflows/
|
||||||
│ ├── ci.yml # Security audit + test/coverage CI jobs
|
│ ├── ci.yml # Security audit + test/coverage CI jobs
|
||||||
│ ├── build-image.yml # Docker image build and push
|
│ ├── build-image.yml # Docker image build and push
|
||||||
@@ -314,6 +313,7 @@ For each connected user the server:
|
|||||||
| See download/target paths | ✗ | ✓ |
|
| See download/target paths | ✗ | ✓ |
|
||||||
| See Sonarr/Radarr links | ✗ | ✓ |
|
| See Sonarr/Radarr links | ✗ | ✓ |
|
||||||
| View status panel | ✗ | ✓ |
|
| View status panel | ✗ | ✓ |
|
||||||
|
| Blocklist & search | ✓ (when import issues OR torrent >1h old AND availability<100%) | ✓ (all downloads) |
|
||||||
|
|
||||||
### Tag Matching
|
### Tag Matching
|
||||||
|
|
||||||
@@ -413,9 +413,18 @@ Each matched download produces an object with:
|
|||||||
| `matchedUserTag` | string / null | Tag label matching the requesting user, or `null` |
|
| `matchedUserTag` | string / null | Tag label matching the requesting user, or `null` |
|
||||||
| `tagBadges` | `{label, matchedUser}[]` / undefined | (Admin `showAll` only) Each tag classified against full Emby user list |
|
| `tagBadges` | `{label, matchedUser}[]` / undefined | (Admin `showAll` only) Each tag classified against full Emby user list |
|
||||||
| `importIssues` | string[] / null | Import warning/error messages |
|
| `importIssues` | string[] / null | Import warning/error messages |
|
||||||
|
| `availableForUpgrade` | boolean / undefined | (History) `true` when outcome is `failed` but the content is already on disk (failed upgrade attempt) |
|
||||||
|
| `canBlocklist` | boolean | `true` if the current user can blocklist this download (admin: always; non-admin: when import issues OR torrent >1h old AND availability<100%) |
|
||||||
| `downloadPath` | string / null | (Admin) Download client path |
|
| `downloadPath` | string / null | (Admin) Download client path |
|
||||||
| `targetPath` | string / null | (Admin) *arr target path |
|
| `targetPath` | string / null | (Admin) *arr target path |
|
||||||
| `arrLink` | string / null | (Admin) Link to *arr web UI |
|
| `arrLink` | string / null | (Admin) Link to *arr web UI |
|
||||||
|
| `arrQueueId` | number / null | (Admin, import-pending only) Sonarr/Radarr queue record id |
|
||||||
|
| `arrType` | `'sonarr'`/`'radarr'` / null | (Admin, import-pending only) Which *arr service owns this queue entry |
|
||||||
|
| `arrInstanceUrl` | string / null | (Admin, import-pending only) Base URL of the *arr instance |
|
||||||
|
| `arrInstanceKey` | string / null | (Admin, import-pending only) API key for the *arr instance |
|
||||||
|
| `arrContentId` | number / null | (Admin, import-pending only) `episodeId` (Sonarr) or `movieId` (Radarr) for triggering a new search |
|
||||||
|
| `arrContentType` | `'episode'`/`'movie'` / null | (Admin, import-pending only) Content type for the search command |
|
||||||
|
| `addedOn` | number / null | (qBittorrent only) Unix timestamp when the torrent was added, used for age-based blocklist eligibility |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -594,6 +603,50 @@ Admin-only per-user download counts (fetches live from APIs, not cached).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### `POST /api/dashboard/blocklist-search`
|
||||||
|
|
||||||
|
Removes a Sonarr/Radarr queue item with `blocklist=true` (preventing the same release being grabbed again), then immediately triggers an `EpisodeSearch` or `MoviesSearch` command.
|
||||||
|
|
||||||
|
**Access:** Admin users can blocklist any download. Non-admin users can only blocklist downloads that meet specific eligibility criteria: import issues are present, OR (for qBittorrent torrents only) the download is more than 1 hour old AND has less than 100% availability. The frontend only shows the button when the user is eligible.
|
||||||
|
|
||||||
|
Requires CSRF token (`X-CSRF-Token` header).
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"arrQueueId": 1234,
|
||||||
|
"arrType": "sonarr",
|
||||||
|
"arrInstanceUrl": "https://sonarr.example.com",
|
||||||
|
"arrInstanceKey": "your-api-key",
|
||||||
|
"arrContentId": 5678,
|
||||||
|
"arrContentType": "episode"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|:--------:|-------------|
|
||||||
|
| `arrQueueId` | Yes | Sonarr/Radarr queue record `id` |
|
||||||
|
| `arrType` | Yes | `"sonarr"` or `"radarr"` |
|
||||||
|
| `arrInstanceUrl` | Yes | Base URL of the *arr instance |
|
||||||
|
| `arrInstanceKey` | Yes | API key for the *arr instance |
|
||||||
|
| `arrContentId` | Yes | `episodeId` (Sonarr) or `movieId` (Radarr) |
|
||||||
|
| `arrContentType` | Yes | `"episode"` or `"movie"` |
|
||||||
|
|
||||||
|
**Response (200):** `{ "ok": true }`
|
||||||
|
|
||||||
|
**Response (400):** Missing or invalid fields.
|
||||||
|
|
||||||
|
**Response (403):** Non-admin user attempting to blocklist without meeting eligibility criteria (no import issues and not an eligible torrent).
|
||||||
|
|
||||||
|
**Response (502):** Upstream *arr call failed.
|
||||||
|
|
||||||
|
**Side Effects:**
|
||||||
|
- Calls `DELETE /api/v3/queue/{id}?removeFromClient=true&blocklist=true` on the *arr instance
|
||||||
|
- Calls `POST /api/v3/command` with `EpisodeSearch`/`MoviesSearch` on the *arr instance
|
||||||
|
- Triggers a background `pollAllServices()` so the next SSE push reflects the removed item
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### `GET /api/history/recent`
|
### `GET /api/history/recent`
|
||||||
|
|
||||||
Returns recently completed (imported or failed) downloads from Sonarr/Radarr history for the authenticated user, filtered to the last `days` days.
|
Returns recently completed (imported or failed) downloads from Sonarr/Radarr history for the authenticated user, filtered to the last `days` days.
|
||||||
@@ -675,14 +728,19 @@ stateDiagram-v2
|
|||||||
|----------|---------|
|
|----------|---------|
|
||||||
| `checkAuthentication()` | On load: check session → show dashboard or login |
|
| `checkAuthentication()` | On load: check session → show dashboard or login |
|
||||||
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
|
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
|
||||||
|
| `goHome()` | Navigate to default view: switch to Active Downloads tab, close status panel, reset showAll |
|
||||||
| `startSSE()` | Open `EventSource` to `/stream`; handles incoming data + first-message loading hide |
|
| `startSSE()` | Open `EventSource` to `/stream`; handles incoming data + first-message loading hide |
|
||||||
| `stopSSE()` | Close `EventSource` and cancel reconnect timer |
|
| `stopSSE()` | Close `EventSource` and cancel reconnect timer |
|
||||||
| `renderDownloads()` | Diff-based card rendering (create/update/remove) |
|
| `renderDownloads()` | Diff-based card rendering (create/update/remove) |
|
||||||
| `createDownloadCard()` | Build DOM for a single download card; renders tag badges |
|
| `createDownloadCard()` | Build DOM for a single download card; renders tag badges, import-issue badge, blocklist button |
|
||||||
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
|
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
|
||||||
|
| `handleBlocklistSearch()` | Confirm dialog → POST `/blocklist-search` → update button state |
|
||||||
| `toggleStatusPanel()` | Show/hide admin status panel |
|
| `toggleStatusPanel()` | Show/hide admin status panel |
|
||||||
| `renderStatusPanel()` | Build status HTML (server, polling, SSE clients, cache) |
|
| `renderStatusPanel()` | Build status HTML (server, polling, SSE clients, cache) |
|
||||||
| `initThemeSwitcher()` | Light / Dark / Mono theme support |
|
| `initThemeSwitcher()` | Light / Dark / Mono theme support |
|
||||||
|
| `loadHistory()` | Fetch `/api/history/recent`, store raw items, call `renderHistory()` |
|
||||||
|
| `renderHistory()` | Filter items by `ignoreAvailable` flag, render history cards |
|
||||||
|
| `createHistoryCard()` | Build DOM for a single history card with outcome/upgrade badges |
|
||||||
|
|
||||||
### Themes
|
### Themes
|
||||||
|
|
||||||
@@ -842,21 +900,23 @@ volumes:
|
|||||||
|
|
||||||
### CI / CD
|
### CI / CD
|
||||||
|
|
||||||
The `.gitea/workflows/` directory contains three pipeline definitions:
|
The `.gitea/workflows/` directory contains five pipeline definitions:
|
||||||
|
|
||||||
| File | Trigger | Purpose |
|
| File | Trigger | Purpose |
|
||||||
|------|---------|--------|
|
|------|---------|--------|
|
||||||
| `ci.yml` | Every push / PR | Security audit (`npm audit --audit-level=high`) + tests with V8 coverage |
|
| `ci.yml` | Every push / PR (all branches) | Security audit (`npm audit --audit-level=high`) + tests with V8 coverage |
|
||||||
| `build-image.yml` | Push to `main` / `develop` | Build and push Docker image to `docker.i3omb.com` |
|
| `build-image.yml` | Push to `release/**` or `develop` | Build and push Docker image to `reg.i3omb.com`. `release/**` pushes versioned + `latest` tags; `develop` pushes a `:develop` tag. |
|
||||||
| `create-release.yml` | Tag push (`v*`) | Create a Gitea release |
|
| `create-release.yml` | Tag push (`v*`) | Generate release notes from git log and create a Gitea release |
|
||||||
|
| `docs-check.yml` | Push / PR touching `**.md` (non-main / non-release branches) | Markdown lint + Mermaid diagram parse validation |
|
||||||
|
| `licence-check.yml` | Push / PR touching `package.json` or `package-lock.json` | Verify all production dependency licences are compatible with MIT |
|
||||||
|
|
||||||
> **Diagrams** are written in Mermaid and render natively in Gitea — no CI workflow required. See [Section 13](#13-diagrams).
|
> **Diagrams** are written in Mermaid and render natively in Gitea — no separate diagram files or CI render step required. See [Section 13](#13-diagrams).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Diagrams
|
## 13. Diagrams
|
||||||
|
|
||||||
All diagrams are written in [Mermaid](https://mermaid.js.org/) and render natively in Gitea and GitHub markdown.
|
All diagrams are written in [Mermaid](https://mermaid.js.org/) and render natively in Gitea and GitHub markdown. No external tooling or PNG exports are required — the source is the diagram.
|
||||||
|
|
||||||
### 13.1 Component Diagram
|
### 13.1 Component Diagram
|
||||||
|
|
||||||
@@ -1133,10 +1193,12 @@ classDiagram
|
|||||||
+GET /user-summary
|
+GET /user-summary
|
||||||
+GET /status
|
+GET /status
|
||||||
+GET /cover-art
|
+GET /cover-art
|
||||||
|
+POST /blocklist-search
|
||||||
buildDownloadPayload()
|
buildDownloadPayload()
|
||||||
extractUserTag()
|
extractUserTag()
|
||||||
buildTagBadges()
|
buildTagBadges()
|
||||||
getEmbyUsers()
|
getEmbyUsers()
|
||||||
|
getImportIssues()
|
||||||
}
|
}
|
||||||
class RequireAuth["requireAuth.js (Middleware)"] {
|
class RequireAuth["requireAuth.js (Middleware)"] {
|
||||||
+requireAuth(req, res, next)
|
+requireAuth(req, res, next)
|
||||||
@@ -1241,6 +1303,13 @@ classDiagram
|
|||||||
+availability string
|
+availability string
|
||||||
+hash string
|
+hash string
|
||||||
+completedAt string
|
+completedAt string
|
||||||
|
+canBlocklist boolean
|
||||||
|
+addedOn number
|
||||||
|
+arrQueueId number
|
||||||
|
+arrType string
|
||||||
|
+arrInstanceUrl string
|
||||||
|
+arrContentId number
|
||||||
|
+arrContentType string
|
||||||
}
|
}
|
||||||
class TagBadge {
|
class TagBadge {
|
||||||
+label string
|
+label string
|
||||||
@@ -1285,6 +1354,7 @@ classDiagram
|
|||||||
+num_seeds number
|
+num_seeds number
|
||||||
+num_leechs number
|
+num_leechs number
|
||||||
+availability number
|
+availability number
|
||||||
|
+added_on number
|
||||||
}
|
}
|
||||||
class SonarrQueueRecord {
|
class SonarrQueueRecord {
|
||||||
+seriesId number
|
+seriesId number
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 304 KiB |
|
Before Width: | Height: | Size: 473 KiB |
|
Before Width: | Height: | Size: 297 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 139 KiB |
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.1.2",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.1.2",
|
"version": "1.3.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.2.0",
|
"version": "1.3.1",
|
||||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let downloads = [];
|
let downloads = [];
|
||||||
let isAdmin = false;
|
let isAdmin = false;
|
||||||
@@ -9,6 +10,8 @@ const SPLASH_MIN_MS = 1200; // minimum splash display time
|
|||||||
let historyDays = parseInt(localStorage.getItem('sofarr-history-days'), 10) || 7;
|
let historyDays = parseInt(localStorage.getItem('sofarr-history-days'), 10) || 7;
|
||||||
let historyRefreshHandle = null;
|
let historyRefreshHandle = null;
|
||||||
const HISTORY_REFRESH_MS = 5 * 60 * 1000; // auto-refresh history every 5 min
|
const HISTORY_REFRESH_MS = 5 * 60 * 1000; // auto-refresh history every 5 min
|
||||||
|
let ignoreAvailable = localStorage.getItem('sofarr-ignore-available') === 'true';
|
||||||
|
let lastHistoryItems = []; // raw items from last fetch, for re-filtering without a network round-trip
|
||||||
|
|
||||||
// SSE stream state
|
// SSE stream state
|
||||||
let sseSource = null;
|
let sseSource = null;
|
||||||
@@ -27,13 +30,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
initThemeSwitcher();
|
initThemeSwitcher();
|
||||||
initTabs();
|
initTabs();
|
||||||
initHistoryControls();
|
initHistoryControls();
|
||||||
|
loadAppVersion();
|
||||||
|
|
||||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
||||||
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
||||||
document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle);
|
document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle);
|
||||||
document.getElementById('status-btn').addEventListener('click', toggleStatusPanel);
|
document.getElementById('status-btn').addEventListener('click', toggleStatusPanel);
|
||||||
|
document.getElementById('title-home-link').addEventListener('click', e => { e.preventDefault(); goHome(); });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function loadAppVersion() {
|
||||||
|
fetch('/health')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.version) {
|
||||||
|
document.getElementById('app-version').textContent = `sofarr v${data.version}`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
function initThemeSwitcher() {
|
function initThemeSwitcher() {
|
||||||
const saved = localStorage.getItem('sofarr-theme') || 'light';
|
const saved = localStorage.getItem('sofarr-theme') || 'light';
|
||||||
document.querySelectorAll('.theme-btn').forEach(btn => {
|
document.querySelectorAll('.theme-btn').forEach(btn => {
|
||||||
@@ -50,6 +66,18 @@ function setTheme(theme) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
closeStatusPanel();
|
||||||
|
// Reset showAll if active
|
||||||
|
if (showAll) {
|
||||||
|
showAll = false;
|
||||||
|
const toggle = document.getElementById('show-all-toggle');
|
||||||
|
if (toggle) toggle.checked = false;
|
||||||
|
startSSE();
|
||||||
|
}
|
||||||
|
activateTab('downloads', true);
|
||||||
|
}
|
||||||
|
|
||||||
function initTabs() {
|
function initTabs() {
|
||||||
const savedTab = localStorage.getItem('sofarr-active-tab') || 'downloads';
|
const savedTab = localStorage.getItem('sofarr-active-tab') || 'downloads';
|
||||||
activateTab(savedTab, false);
|
activateTab(savedTab, false);
|
||||||
@@ -430,6 +458,49 @@ function updateDownloadCard(card, download) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBlocklistSearch(btn, download) {
|
||||||
|
if (!confirm(`Blocklist "${download.title}" and trigger a new search?\n\nThis will:\n• Remove the download from the download client\n• Add this release to the blocklist\n• Trigger an automatic search for a new release`)) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '⏳ Working…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/dashboard/blocklist-search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
arrQueueId: download.arrQueueId,
|
||||||
|
arrType: download.arrType,
|
||||||
|
arrInstanceUrl: download.arrInstanceUrl,
|
||||||
|
arrInstanceKey: download.arrInstanceKey,
|
||||||
|
arrContentId: download.arrContentId,
|
||||||
|
arrContentType: download.arrContentType
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.textContent = '✓ Done — searching…';
|
||||||
|
btn.className = 'blocklist-search-btn success';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Blocklist] Error:', err);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '⛔ Blocklist & Search';
|
||||||
|
btn.className = 'blocklist-search-btn error';
|
||||||
|
btn.title = `Failed: ${err.message}`;
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.className = 'blocklist-search-btn';
|
||||||
|
btn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search';
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createDownloadCard(download) {
|
function createDownloadCard(download) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = `download-card ${download.type}`;
|
card.className = `download-card ${download.type}`;
|
||||||
@@ -485,6 +556,15 @@ function createDownloadCard(download) {
|
|||||||
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
|
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
|
||||||
header.appendChild(issueBadge);
|
header.appendChild(issueBadge);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((isAdmin || download.canBlocklist) && download.arrQueueId) {
|
||||||
|
const blBtn = document.createElement('button');
|
||||||
|
blBtn.className = 'blocklist-search-btn';
|
||||||
|
blBtn.textContent = '⛔ Blocklist & Search';
|
||||||
|
blBtn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search';
|
||||||
|
blBtn.addEventListener('click', () => handleBlocklistSearch(blBtn, download));
|
||||||
|
header.appendChild(blBtn);
|
||||||
|
}
|
||||||
|
|
||||||
const title = document.createElement('h3');
|
const title = document.createElement('h3');
|
||||||
title.className = 'download-title';
|
title.className = 'download-title';
|
||||||
@@ -857,6 +937,7 @@ function hideLoading() {
|
|||||||
function initHistoryControls() {
|
function initHistoryControls() {
|
||||||
const daysInput = document.getElementById('history-days');
|
const daysInput = document.getElementById('history-days');
|
||||||
const refreshBtn = document.getElementById('history-refresh-btn');
|
const refreshBtn = document.getElementById('history-refresh-btn');
|
||||||
|
const ignoreToggle = document.getElementById('ignore-available-toggle');
|
||||||
if (daysInput) {
|
if (daysInput) {
|
||||||
daysInput.addEventListener('change', () => {
|
daysInput.addEventListener('change', () => {
|
||||||
const v = parseInt(daysInput.value, 10);
|
const v = parseInt(daysInput.value, 10);
|
||||||
@@ -870,6 +951,14 @@ function initHistoryControls() {
|
|||||||
if (refreshBtn) {
|
if (refreshBtn) {
|
||||||
refreshBtn.addEventListener('click', () => loadHistory(true));
|
refreshBtn.addEventListener('click', () => loadHistory(true));
|
||||||
}
|
}
|
||||||
|
if (ignoreToggle) {
|
||||||
|
ignoreToggle.checked = ignoreAvailable;
|
||||||
|
ignoreToggle.addEventListener('change', () => {
|
||||||
|
ignoreAvailable = ignoreToggle.checked;
|
||||||
|
localStorage.setItem('sofarr-ignore-available', ignoreAvailable);
|
||||||
|
renderHistory(lastHistoryItems);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startHistoryRefresh() {
|
function startHistoryRefresh() {
|
||||||
@@ -885,6 +974,7 @@ function stopHistoryRefresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearHistory() {
|
function clearHistory() {
|
||||||
|
lastHistoryItems = [];
|
||||||
document.getElementById('history-list').innerHTML = '';
|
document.getElementById('history-list').innerHTML = '';
|
||||||
document.getElementById('no-history').style.display = 'none';
|
document.getElementById('no-history').style.display = 'none';
|
||||||
document.getElementById('history-error').style.display = 'none';
|
document.getElementById('history-error').style.display = 'none';
|
||||||
@@ -908,7 +998,8 @@ async function loadHistory(forceRefresh = false) {
|
|||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
loadingEl.style.display = 'none';
|
loadingEl.style.display = 'none';
|
||||||
renderHistory(data.history || []);
|
lastHistoryItems = data.history || [];
|
||||||
|
renderHistory(lastHistoryItems);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
loadingEl.style.display = 'none';
|
loadingEl.style.display = 'none';
|
||||||
errorEl.textContent = 'Failed to load history.';
|
errorEl.textContent = 'Failed to load history.';
|
||||||
@@ -921,12 +1012,15 @@ function renderHistory(items) {
|
|||||||
const listEl = document.getElementById('history-list');
|
const listEl = document.getElementById('history-list');
|
||||||
const noHistoryEl = document.getElementById('no-history');
|
const noHistoryEl = document.getElementById('no-history');
|
||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
if (!items.length) {
|
const visible = ignoreAvailable
|
||||||
|
? items.filter(item => !(item.outcome === 'failed' && item.availableForUpgrade))
|
||||||
|
: items;
|
||||||
|
if (!visible.length) {
|
||||||
noHistoryEl.style.display = 'block';
|
noHistoryEl.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
noHistoryEl.style.display = 'none';
|
noHistoryEl.style.display = 'none';
|
||||||
items.forEach(item => listEl.appendChild(createHistoryCard(item)));
|
visible.forEach(item => listEl.appendChild(createHistoryCard(item)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHistoryCard(item) {
|
function createHistoryCard(item) {
|
||||||
@@ -961,6 +1055,14 @@ function createHistoryCard(item) {
|
|||||||
outcomeBadge.textContent = item.outcome === 'imported' ? '✓ Imported' : '✗ Failed';
|
outcomeBadge.textContent = item.outcome === 'imported' ? '✓ Imported' : '✗ Failed';
|
||||||
header.appendChild(outcomeBadge);
|
header.appendChild(outcomeBadge);
|
||||||
|
|
||||||
|
if (item.availableForUpgrade) {
|
||||||
|
const upgradeBadge = document.createElement('span');
|
||||||
|
upgradeBadge.className = 'history-upgrade-badge';
|
||||||
|
upgradeBadge.title = 'A previous version of this item is available. An upgrade download has failed.';
|
||||||
|
upgradeBadge.textContent = '⬆ Available';
|
||||||
|
header.appendChild(upgradeBadge);
|
||||||
|
}
|
||||||
|
|
||||||
if (item.instanceName) {
|
if (item.instanceName) {
|
||||||
const instBadge = document.createElement('span');
|
const instBadge = document.createElement('span');
|
||||||
instBadge.className = 'history-instance-badge';
|
instBadge.className = 'history-instance-badge';
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
<!-- Dashboard -->
|
<!-- Dashboard -->
|
||||||
<div id="dashboard-container" class="dashboard-container" style="display: none;">
|
<div id="dashboard-container" class="dashboard-container" style="display: none;">
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<h1>sofarr</h1>
|
<h1><a href="#" class="title-link" id="title-home-link"><img src="favicon-192.png" alt="" class="title-logo">sofarr</a></h1>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
<div class="theme-switcher">
|
<div class="theme-switcher">
|
||||||
<button class="theme-btn active" data-theme="light">Light</button>
|
<button class="theme-btn active" data-theme="light">Light</button>
|
||||||
@@ -98,6 +98,10 @@
|
|||||||
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
|
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
|
||||||
<span class="history-days-label">days</span>
|
<span class="history-days-label">days</span>
|
||||||
<button id="history-refresh-btn" class="history-refresh-btn" title="Refresh history">↻</button>
|
<button id="history-refresh-btn" class="history-refresh-btn" title="Refresh history">↻</button>
|
||||||
|
<label class="history-toggle-label" id="ignore-available-label" data-tooltip="Hide failed downloads where the item is already available on disk (i.e. a failed upgrade attempt)">
|
||||||
|
<input type="checkbox" id="ignore-available-toggle">
|
||||||
|
<span>Hide upgrade failures</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="history-loading" class="history-loading" style="display: none;">Loading history...</div>
|
<div id="history-loading" class="history-loading" style="display: none;">Loading history...</div>
|
||||||
@@ -112,6 +116,7 @@
|
|||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<p>Ensure your media is tagged with your username in Sonarr/Radarr to match downloads to users.</p>
|
<p>Ensure your media is tagged with your username in Sonarr/Radarr to match downloads to users.</p>
|
||||||
|
<a href="https://git.i3omb.com/Gandalf/sofarr" target="_blank" rel="noopener noreferrer" class="app-version" id="app-version"></a>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -714,6 +714,41 @@ body {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-toggle-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
margin-left: 4px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-toggle-label[data-tooltip]:hover::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: pre-line;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
max-width: 280px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-toggle-label input[type="checkbox"] {
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent, #2980b9);
|
||||||
|
}
|
||||||
|
|
||||||
.history-loading,
|
.history-loading,
|
||||||
.history-error,
|
.history-error,
|
||||||
.no-history {
|
.no-history {
|
||||||
@@ -779,7 +814,8 @@ body {
|
|||||||
|
|
||||||
.history-type-badge,
|
.history-type-badge,
|
||||||
.history-outcome-badge,
|
.history-outcome-badge,
|
||||||
.history-instance-badge {
|
.history-instance-badge,
|
||||||
|
.history-upgrade-badge {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
@@ -787,6 +823,12 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-upgrade-badge {
|
||||||
|
background: #e67e22;
|
||||||
|
color: #fff;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.history-type-badge.series {
|
.history-type-badge.series {
|
||||||
background: var(--badge-series-bg, #2980b9);
|
background: var(--badge-series-bg, #2980b9);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -866,6 +908,41 @@ body {
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-version {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-version:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-link {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-logo {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Login ===== */
|
/* ===== Login ===== */
|
||||||
.login-container {
|
.login-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1060,20 +1137,54 @@ body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 6px);
|
top: calc(100% + 6px);
|
||||||
left: 0;
|
left: 0;
|
||||||
background: #424242;
|
z-index: 20;
|
||||||
color: #fff;
|
background: var(--surface);
|
||||||
padding: 8px 12px;
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.7rem;
|
padding: 8px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
z-index: 100;
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blocklist-search-btn {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--error, #e74c3c);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--error, #e74c3c);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocklist-search-btn:hover:not(:disabled) {
|
||||||
|
background: var(--error, #e74c3c);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocklist-search-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocklist-search-btn.success {
|
||||||
|
border-color: var(--success, #27ae60);
|
||||||
|
color: var(--success, #27ae60);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocklist-search-btn.error {
|
||||||
|
background: var(--error, #e74c3c);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.download-user-badge {
|
.download-user-badge {
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
/**
|
/**
|
||||||
* Express application factory — imported by both server/index.js (production)
|
* Express application factory — imported by both server/index.js (production)
|
||||||
* and the test suite. Keeping app creation separate from app.listen() means
|
* and the test suite. Keeping app creation separate from app.listen() means
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const cookieParser = require('cookie-parser');
|
const cookieParser = require('cookie-parser');
|
||||||
@@ -197,7 +197,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
|||||||
// Used by Docker HEALTHCHECK and orchestrators.
|
// Used by Docker HEALTHCHECK and orchestrators.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', uptime: process.uptime() });
|
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/ready', (req, res) => {
|
app.get('/ready', (req, res) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
function requireAuth(req, res, next) {
|
function requireAuth(req, res, next) {
|
||||||
const signed = !!process.env.COOKIE_SECRET;
|
const signed = !!process.env.COOKIE_SECRET;
|
||||||
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
|
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
/**
|
/**
|
||||||
* CSRF protection using the double-submit cookie pattern.
|
* CSRF protection using the double-submit cookie pattern.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
@@ -94,6 +95,23 @@ function getRadarrLink(movie) {
|
|||||||
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine if a download can be blocklisted by the current user
|
||||||
|
// Admins: always true (they have arrQueueId)
|
||||||
|
// Non-admins: true if importIssues OR (torrent >1h old AND availability<100%)
|
||||||
|
function canBlocklist(download, isAdmin) {
|
||||||
|
if (isAdmin) return true;
|
||||||
|
if (download.importIssues && download.importIssues.length > 0) return true;
|
||||||
|
if (download.qbittorrent && download.addedOn && download.availability) {
|
||||||
|
const oneHourAgo = Date.now() - 3600000; // 1 hour in ms
|
||||||
|
const addedOn = new Date(download.addedOn).getTime();
|
||||||
|
const isOldEnough = addedOn < oneHourAgo;
|
||||||
|
const availability = parseFloat(download.availability);
|
||||||
|
const isLowAvailability = availability < 100;
|
||||||
|
return isOldEnough && isLowAvailability;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Extract episode info from a Sonarr queue/history record.
|
// Extract episode info from a Sonarr queue/history record.
|
||||||
// Returns { season, episode, title } or null if data is missing.
|
// Returns { season, episode, title } or null if data is missing.
|
||||||
function extractEpisode(record) {
|
function extractEpisode(record) {
|
||||||
@@ -321,7 +339,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
dlObj.downloadPath = slot.storage || null;
|
dlObj.downloadPath = slot.storage || null;
|
||||||
dlObj.targetPath = series.path || null;
|
dlObj.targetPath = series.path || null;
|
||||||
dlObj.arrLink = getSonarrLink(series);
|
dlObj.arrLink = getSonarrLink(series);
|
||||||
|
dlObj.arrQueueId = sonarrMatch.id;
|
||||||
|
dlObj.arrType = 'sonarr';
|
||||||
|
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||||
|
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||||
|
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
||||||
|
dlObj.arrContentType = 'episode';
|
||||||
}
|
}
|
||||||
|
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin);
|
||||||
userDownloads.push(dlObj);
|
userDownloads.push(dlObj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,7 +388,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
dlObj.downloadPath = slot.storage || null;
|
dlObj.downloadPath = slot.storage || null;
|
||||||
dlObj.targetPath = movie.path || null;
|
dlObj.targetPath = movie.path || null;
|
||||||
dlObj.arrLink = getRadarrLink(movie);
|
dlObj.arrLink = getRadarrLink(movie);
|
||||||
|
dlObj.arrQueueId = radarrMatch.id;
|
||||||
|
dlObj.arrType = 'radarr';
|
||||||
|
dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||||
|
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||||
|
dlObj.arrContentId = radarrMatch.movieId || null;
|
||||||
|
dlObj.arrContentType = 'movie';
|
||||||
}
|
}
|
||||||
|
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin);
|
||||||
userDownloads.push(dlObj);
|
userDownloads.push(dlObj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -520,7 +552,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
download.downloadPath = download.savePath || null;
|
download.downloadPath = download.savePath || null;
|
||||||
download.targetPath = series.path || null;
|
download.targetPath = series.path || null;
|
||||||
download.arrLink = getSonarrLink(series);
|
download.arrLink = getSonarrLink(series);
|
||||||
|
download.arrQueueId = sonarrMatch.id;
|
||||||
|
download.arrType = 'sonarr';
|
||||||
|
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||||
|
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||||
|
download.arrContentId = sonarrMatch.episodeId || null;
|
||||||
|
download.arrContentType = 'episode';
|
||||||
}
|
}
|
||||||
|
download.canBlocklist = canBlocklist(download, isAdmin);
|
||||||
userDownloads.push(download);
|
userDownloads.push(download);
|
||||||
continue; // Skip to next torrent
|
continue; // Skip to next torrent
|
||||||
}
|
}
|
||||||
@@ -555,7 +594,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
download.downloadPath = download.savePath || null;
|
download.downloadPath = download.savePath || null;
|
||||||
download.targetPath = movie.path || null;
|
download.targetPath = movie.path || null;
|
||||||
download.arrLink = getRadarrLink(movie);
|
download.arrLink = getRadarrLink(movie);
|
||||||
|
download.arrQueueId = radarrMatch.id;
|
||||||
|
download.arrType = 'radarr';
|
||||||
|
download.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
||||||
|
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||||
|
download.arrContentId = radarrMatch.movieId || null;
|
||||||
|
download.arrContentType = 'movie';
|
||||||
}
|
}
|
||||||
|
download.canBlocklist = canBlocklist(download, isAdmin);
|
||||||
userDownloads.push(download);
|
userDownloads.push(download);
|
||||||
continue; // Skip to next torrent
|
continue; // Skip to next torrent
|
||||||
}
|
}
|
||||||
@@ -893,7 +939,8 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
||||||
const issues = getImportIssues(sonarrMatch);
|
const issues = getImportIssues(sonarrMatch);
|
||||||
if (issues) dlObj.importIssues = issues;
|
if (issues) dlObj.importIssues = issues;
|
||||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); }
|
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); dlObj.arrQueueId = sonarrMatch.id; dlObj.arrType = 'sonarr'; dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; dlObj.arrContentId = sonarrMatch.episodeId || null; dlObj.arrContentType = 'episode'; }
|
||||||
|
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin);
|
||||||
userDownloads.push(dlObj);
|
userDownloads.push(dlObj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -912,7 +959,8 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
||||||
const issues = getImportIssues(radarrMatch);
|
const issues = getImportIssues(radarrMatch);
|
||||||
if (issues) dlObj.importIssues = issues;
|
if (issues) dlObj.importIssues = issues;
|
||||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); }
|
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); dlObj.arrQueueId = radarrMatch.id; dlObj.arrType = 'radarr'; dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; dlObj.arrInstanceKey = radarrMatch._instanceKey || null; dlObj.arrContentId = radarrMatch.movieId || null; dlObj.arrContentType = 'movie'; }
|
||||||
|
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin);
|
||||||
userDownloads.push(dlObj);
|
userDownloads.push(dlObj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -979,7 +1027,8 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
const download = mapTorrentToDownload(torrent);
|
const download = mapTorrentToDownload(torrent);
|
||||||
Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodes: gatherEpisodes(torrentNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodes: gatherEpisodes(torrentNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
||||||
const issues = getImportIssues(sonarrMatch); if (issues) download.importIssues = issues;
|
const issues = getImportIssues(sonarrMatch); if (issues) download.importIssues = issues;
|
||||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); }
|
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); download.arrQueueId = sonarrMatch.id; download.arrType = 'sonarr'; download.arrInstanceUrl = sonarrMatch._instanceUrl || null; download.arrInstanceKey = sonarrMatch._instanceKey || null; download.arrContentId = sonarrMatch.episodeId || null; download.arrContentType = 'episode'; }
|
||||||
|
download.canBlocklist = canBlocklist(download, isAdmin);
|
||||||
userDownloads.push(download); continue;
|
userDownloads.push(download); continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -995,7 +1044,8 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
const download = mapTorrentToDownload(torrent);
|
const download = mapTorrentToDownload(torrent);
|
||||||
Object.assign(download, { type: 'movie', coverArt: getCoverArt(movie), movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
Object.assign(download, { type: 'movie', coverArt: getCoverArt(movie), movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
||||||
const issues = getImportIssues(radarrMatch); if (issues) download.importIssues = issues;
|
const issues = getImportIssues(radarrMatch); if (issues) download.importIssues = issues;
|
||||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); }
|
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); download.arrQueueId = radarrMatch.id; download.arrType = 'radarr'; download.arrInstanceUrl = radarrMatch._instanceUrl || null; download.arrInstanceKey = radarrMatch._instanceKey || null; download.arrContentId = radarrMatch.movieId || null; download.arrContentType = 'movie'; }
|
||||||
|
download.canBlocklist = canBlocklist(download, isAdmin);
|
||||||
userDownloads.push(download); continue;
|
userDownloads.push(download); continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1059,4 +1109,68 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/dashboard/blocklist-search
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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'
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
router.post('/blocklist-search', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user.isAdmin) {
|
||||||
|
return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentType } = req.body;
|
||||||
|
|
||||||
|
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrInstanceKey || !arrContentId || !arrContentType) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
if (arrType !== 'sonarr' && arrType !== 'radarr') {
|
||||||
|
return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = { 'X-Api-Key': arrInstanceKey };
|
||||||
|
|
||||||
|
// Step 1: Remove from queue with blocklist=true
|
||||||
|
await axios.delete(`${arrInstanceUrl}/api/v3/queue/${arrQueueId}`, {
|
||||||
|
headers,
|
||||||
|
params: { removeFromClient: true, blocklist: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Trigger a new automatic search
|
||||||
|
let commandBody;
|
||||||
|
if (arrType === 'sonarr' && arrContentType === 'episode') {
|
||||||
|
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
|
||||||
|
} else if (arrType === 'radarr' && arrContentType === 'movie') {
|
||||||
|
commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandBody) {
|
||||||
|
await axios.post(`${arrInstanceUrl}/api/v3/command`, commandBody, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate the poll cache so the next SSE push reflects the removed item
|
||||||
|
const { pollAllServices } = require('../utils/poller');
|
||||||
|
pollAllServices().catch(() => {});
|
||||||
|
|
||||||
|
console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId} by ${user.name}`);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Dashboard] blocklist-search error:', sanitizeError(err));
|
||||||
|
res.status(502).json({ error: 'Failed to blocklist and search', details: sanitizeError(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
@@ -114,6 +115,75 @@ function gatherEpisodes(titleLower, records) {
|
|||||||
return episodes;
|
return episodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicate history items so that for each unique content item (episode or
|
||||||
|
* movie) only the most-recent record is shown, with the following rules:
|
||||||
|
*
|
||||||
|
* - If the most recent event is 'imported' → show it; suppress older failures.
|
||||||
|
* - If the most recent event is 'failed' and the item currently has a file
|
||||||
|
* (hasFile = true) → show the failure but flag it as availableForUpgrade:true
|
||||||
|
* so the UI can indicate the item is available but an upgrade is in progress.
|
||||||
|
* - If the most recent event is 'failed' and hasFile is false → show normally.
|
||||||
|
*
|
||||||
|
* Items are keyed by: type + instanceName + contentId (episodeId or movieId).
|
||||||
|
* Records without a contentId fall through unchanged (no deduplication possible).
|
||||||
|
*
|
||||||
|
* @param {Array} items - Already-built history items (unsorted)
|
||||||
|
* @param {Array} sonarrRaw - Raw Sonarr records (for hasFile lookup)
|
||||||
|
* @param {Array} radarrRaw - Raw Radarr records (for hasFile lookup)
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
function deduplicateHistoryItems(items, sonarrRaw, radarrRaw) {
|
||||||
|
// Build hasFile lookup: contentId → boolean
|
||||||
|
const sonarrHasFile = new Map();
|
||||||
|
for (const r of sonarrRaw) {
|
||||||
|
const id = r.episodeId;
|
||||||
|
if (id != null) {
|
||||||
|
const hf = r.episode && r.episode.hasFile != null ? r.episode.hasFile : undefined;
|
||||||
|
if (hf !== undefined && !sonarrHasFile.has(id)) sonarrHasFile.set(id, hf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const radarrHasFile = new Map();
|
||||||
|
for (const r of radarrRaw) {
|
||||||
|
const id = r.movieId;
|
||||||
|
if (id != null) {
|
||||||
|
const hf = r.movie && r.movie.hasFile != null ? r.movie.hasFile : undefined;
|
||||||
|
if (hf !== undefined && !radarrHasFile.has(id)) radarrHasFile.set(id, hf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group items by dedup key; preserve insertion order (newest first from caller)
|
||||||
|
const groups = new Map();
|
||||||
|
const noKey = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const cid = item._contentId;
|
||||||
|
if (cid == null) { noKey.push(item); continue; }
|
||||||
|
const key = `${item.type}|${item.instanceName}|${cid}`;
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key).push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [...noKey];
|
||||||
|
for (const [, group] of groups) {
|
||||||
|
// group[0] is the most recent (items are pushed in date-descending order)
|
||||||
|
const best = group[0];
|
||||||
|
if (best.outcome === 'imported') {
|
||||||
|
result.push(best);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (best.outcome === 'failed') {
|
||||||
|
const hasFile = best.type === 'series'
|
||||||
|
? sonarrHasFile.get(best._contentId)
|
||||||
|
: radarrHasFile.get(best._contentId);
|
||||||
|
if (hasFile) best.availableForUpgrade = true;
|
||||||
|
result.push(best);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push(best);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function getSonarrLink(series) {
|
function getSonarrLink(series) {
|
||||||
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
||||||
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
||||||
@@ -223,7 +293,8 @@ router.get('/recent', requireAuth, async (req, res) => {
|
|||||||
arrLink: getSonarrLink(series),
|
arrLink: getSonarrLink(series),
|
||||||
allTags,
|
allTags,
|
||||||
matchedUserTag: matchedUserTag || null,
|
matchedUserTag: matchedUserTag || null,
|
||||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined,
|
||||||
|
_contentId: record.episodeId != null ? record.episodeId : null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
@@ -270,7 +341,8 @@ router.get('/recent', requireAuth, async (req, res) => {
|
|||||||
arrLink: getRadarrLink(movie),
|
arrLink: getRadarrLink(movie),
|
||||||
allTags,
|
allTags,
|
||||||
matchedUserTag: matchedUserTag || null,
|
matchedUserTag: matchedUserTag || null,
|
||||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined,
|
||||||
|
_contentId: record.movieId != null ? record.movieId : null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
@@ -286,16 +358,24 @@ router.get('/recent', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort newest first
|
// Deduplicate: for each content item keep only the most-recent record,
|
||||||
historyItems.sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
|
// suppressing failures that were superseded by a successful import.
|
||||||
|
// Must run before sort so insertion order (newest-first from arr API) is preserved.
|
||||||
|
const dedupedItems = deduplicateHistoryItems(historyItems, sonarrHistory, radarrHistory);
|
||||||
|
|
||||||
console.log(`[History] Returning ${historyItems.length} items for user ${user.name} (days=${days}, showAll=${showAll})`);
|
// Strip internal dedup key before sending to client
|
||||||
|
for (const item of dedupedItems) delete item._contentId;
|
||||||
|
|
||||||
|
// Sort newest first
|
||||||
|
dedupedItems.sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
|
||||||
|
|
||||||
|
console.log(`[History] Returning ${dedupedItems.length} items for user ${user.name} (days=${days}, showAll=${showAll})`);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
user: user.name,
|
user: user.name,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
days,
|
days,
|
||||||
history: historyItems
|
history: dedupedItems
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[History] Error:', err.message);
|
console.error('[History] Error:', err.message);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const { logToFile } = require('./logger');
|
const { logToFile } = require('./logger');
|
||||||
|
|
||||||
class MemoryCache {
|
class MemoryCache {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const { logToFile } = require('./logger');
|
const { logToFile } = require('./logger');
|
||||||
|
|
||||||
// Validate that a configured service URL is well-formed and uses http(s).
|
// Validate that a configured service URL is well-formed and uses http(s).
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const cache = require('./cache');
|
const cache = require('./cache');
|
||||||
const { getSonarrInstances, getRadarrInstances } = require('./config');
|
const { getSonarrInstances, getRadarrInstances } = require('./config');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
//
|
//
|
||||||
// Docker secrets support: if an environment variable named FOO_FILE is set,
|
// Docker secrets support: if an environment variable named FOO_FILE is set,
|
||||||
// read its contents from the file at that path and expose it as FOO.
|
// read its contents from the file at that path and expose it as FOO.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const cache = require('./cache');
|
const cache = require('./cache');
|
||||||
const { getTorrents } = require('./qbittorrent');
|
const { getTorrents } = require('./qbittorrent');
|
||||||
@@ -160,8 +160,11 @@ async function pollAllServices() {
|
|||||||
records: sonarrQueues.flatMap(q => {
|
records: sonarrQueues.flatMap(q => {
|
||||||
const inst = sonarrInstances.find(i => i.id === q.instance);
|
const inst = sonarrInstances.find(i => i.id === q.instance);
|
||||||
const url = inst ? inst.url : null;
|
const url = inst ? inst.url : null;
|
||||||
|
const key = inst ? inst.apiKey : null;
|
||||||
return (q.data.records || []).map(r => {
|
return (q.data.records || []).map(r => {
|
||||||
if (r.series) r.series._instanceUrl = url;
|
if (r.series) r.series._instanceUrl = url;
|
||||||
|
r._instanceUrl = url;
|
||||||
|
r._instanceKey = key;
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -175,8 +178,11 @@ async function pollAllServices() {
|
|||||||
records: radarrQueues.flatMap(q => {
|
records: radarrQueues.flatMap(q => {
|
||||||
const inst = radarrInstances.find(i => i.id === q.instance);
|
const inst = radarrInstances.find(i => i.id === q.instance);
|
||||||
const url = inst ? inst.url : null;
|
const url = inst ? inst.url : null;
|
||||||
|
const key = inst ? inst.apiKey : null;
|
||||||
return (q.data.records || []).map(r => {
|
return (q.data.records || []).map(r => {
|
||||||
if (r.movie) r.movie._instanceUrl = url;
|
if (r.movie) r.movie._instanceUrl = url;
|
||||||
|
r._instanceUrl = url;
|
||||||
|
r._instanceKey = key;
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { logToFile } = require('./logger');
|
const { logToFile } = require('./logger');
|
||||||
const { getQbittorrentInstances } = require('./config');
|
const { getQbittorrentInstances } = require('./config');
|
||||||
@@ -204,6 +205,7 @@ function mapTorrentToDownload(torrent) {
|
|||||||
category: torrent.category,
|
category: torrent.category,
|
||||||
tags: torrent.tags,
|
tags: torrent.tags,
|
||||||
savePath: torrent.content_path || torrent.save_path || null,
|
savePath: torrent.content_path || torrent.save_path || null,
|
||||||
|
addedOn: torrent.added_on || null,
|
||||||
qbittorrent: true
|
qbittorrent: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Copyright (c) 2025 Gordon Bolton. MIT License.
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
// Query-param secrets (SABnzbd apikey, generic token/password params)
|
// Query-param secrets (SABnzbd apikey, generic token/password params)
|
||||||
const QUERY_SECRET_PATTERN = /([?&](?:apikey|token|password|api_key|key|secret)=)[^&\s#]*/gi;
|
const QUERY_SECRET_PATTERN = /([?&](?:apikey|token|password|api_key|key|secret)=)[^&\s#]*/gi;
|
||||||
// HTTP auth header values (X-Api-Key, X-MediaBrowser-Token, Authorization, X-Emby-Authorization)
|
// HTTP auth header values (X-Api-Key, X-MediaBrowser-Token, Authorization, X-Emby-Authorization)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
/**
|
/**
|
||||||
* Persistent token store backed by a JSON file.
|
* Persistent token store backed by a JSON file.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
/**
|
/**
|
||||||
* Integration tests for authentication routes.
|
* Integration tests for authentication routes.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
/**
|
/**
|
||||||
* Integration tests for health and readiness endpoints.
|
* Integration tests for health and readiness endpoints.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
/**
|
/**
|
||||||
* Integration tests for GET /api/history/recent
|
* Integration tests for GET /api/history/recent
|
||||||
*
|
*
|
||||||
@@ -97,6 +98,60 @@ const RADARR_RECORD_IMPORTED = {
|
|||||||
movieId: 20
|
movieId: 20
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Deduplication fixtures — same episodeId 55, episode 1 failed then imported
|
||||||
|
const SONARR_RECORD_FAILED_EP55 = {
|
||||||
|
id: 110,
|
||||||
|
eventType: 'downloadFailed',
|
||||||
|
sourceTitle: 'Show.S02E01.720p',
|
||||||
|
date: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
|
||||||
|
quality: { quality: { name: '720p' } },
|
||||||
|
data: { message: 'Download failed' },
|
||||||
|
episodeId: 55,
|
||||||
|
episode: { seasonNumber: 2, episodeNumber: 1, title: 'Pilot', hasFile: false },
|
||||||
|
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||||
|
seriesId: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
const SONARR_RECORD_IMPORTED_EP55 = {
|
||||||
|
id: 111,
|
||||||
|
eventType: 'downloadFolderImported',
|
||||||
|
sourceTitle: 'Show.S02E01.720p',
|
||||||
|
date: new Date().toISOString(), // now (more recent)
|
||||||
|
quality: { quality: { name: '720p' } },
|
||||||
|
episodeId: 55,
|
||||||
|
episode: { seasonNumber: 2, episodeNumber: 1, title: 'Pilot', hasFile: true },
|
||||||
|
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||||
|
seriesId: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
// Failed, still failing (hasFile=false) — most recent is a failure with no file
|
||||||
|
const SONARR_RECORD_FAILED_EP56 = {
|
||||||
|
id: 112,
|
||||||
|
eventType: 'downloadFailed',
|
||||||
|
sourceTitle: 'Show.S02E02.720p',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
quality: { quality: { name: '720p' } },
|
||||||
|
data: { message: 'No seeders' },
|
||||||
|
episodeId: 56,
|
||||||
|
episode: { seasonNumber: 2, episodeNumber: 2, title: 'Episode 2', hasFile: false },
|
||||||
|
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||||
|
seriesId: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
// Failed but hasFile=true — episode is available, failure is an upgrade attempt
|
||||||
|
const SONARR_RECORD_FAILED_EP57_HAS_FILE = {
|
||||||
|
id: 113,
|
||||||
|
eventType: 'downloadFailed',
|
||||||
|
sourceTitle: 'Show.S02E03.720p',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
quality: { quality: { name: '720p' } },
|
||||||
|
data: { message: 'Upgrade failed' },
|
||||||
|
episodeId: 57,
|
||||||
|
episode: { seasonNumber: 2, episodeNumber: 3, title: 'Episode 3', hasFile: true },
|
||||||
|
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||||
|
seriesId: 10
|
||||||
|
};
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
function interceptLogin(userBody = EMBY_USER, authBody = EMBY_AUTH) {
|
function interceptLogin(userBody = EMBY_USER, authBody = EMBY_AUTH) {
|
||||||
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, authBody);
|
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, authBody);
|
||||||
@@ -271,6 +326,63 @@ describe('GET /api/history/recent', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('deduplication', () => {
|
||||||
|
it('suppresses a failed record when the same episode was subsequently imported', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
// API returns newest-first: imported (now) before failed (1hr ago)
|
||||||
|
setHistory([SONARR_RECORD_IMPORTED_EP55, SONARR_RECORD_FAILED_EP55], []);
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const ep55Items = res.body.history.filter(h => h.seriesName === 'My Show' && h.title.includes('S02E01'));
|
||||||
|
expect(ep55Items).toHaveLength(1);
|
||||||
|
expect(ep55Items[0].outcome).toBe('imported');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a failed record as-is when there is no successful import and hasFile is false', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
setHistory([SONARR_RECORD_FAILED_EP56], []);
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const item = res.body.history.find(h => h.title && h.title.includes('S02E02'));
|
||||||
|
expect(item).toBeDefined();
|
||||||
|
expect(item.outcome).toBe('failed');
|
||||||
|
expect(item.availableForUpgrade).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a failed record as availableForUpgrade when the episode hasFile is true', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
setHistory([SONARR_RECORD_FAILED_EP57_HAS_FILE], []);
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const item = res.body.history.find(h => h.title && h.title.includes('S02E03'));
|
||||||
|
expect(item).toBeDefined();
|
||||||
|
expect(item.outcome).toBe('failed');
|
||||||
|
expect(item.availableForUpgrade).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose _contentId in the response', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
setHistory([SONARR_RECORD_IMPORTED_EP55], []);
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
for (const item of res.body.history) {
|
||||||
|
expect(item).not.toHaveProperty('_contentId');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('response shape', () => {
|
describe('response shape', () => {
|
||||||
it('returns correct top-level fields', async () => {
|
it('returns correct top-level fields', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
import { vi, beforeEach, afterEach } from 'vitest';
|
import { vi, beforeEach, afterEach } from 'vitest';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
/**
|
/**
|
||||||
* Tests for server/utils/config.js
|
* Tests for server/utils/config.js
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
/**
|
/**
|
||||||
* Unit tests for server/utils/historyFetcher.js
|
* Unit tests for server/utils/historyFetcher.js
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
/**
|
/**
|
||||||
* Tests for server/utils/qbittorrent.js pure utility functions.
|
* Tests for server/utils/qbittorrent.js pure utility functions.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
/**
|
/**
|
||||||
* Tests for server/middleware/requireAuth.js
|
* Tests for server/middleware/requireAuth.js
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
/**
|
/**
|
||||||
* Tests for server/utils/sanitizeError.js
|
* Tests for server/utils/sanitizeError.js
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
/**
|
/**
|
||||||
* Tests for server/utils/tokenStore.js
|
* Tests for server/utils/tokenStore.js
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
/**
|
/**
|
||||||
* Tests for server/middleware/verifyCsrf.js
|
* Tests for server/middleware/verifyCsrf.js
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||