docs(ARCHITECTURE): update to reflect develop-refactor2 changes
- Add Download Matching & Assembly Services section (3.3) covering DownloadBuilder, DownloadMatcher, DownloadAssembler, TagMatcher, and WebhookStatus - Update High-Level Architecture diagram with /api/status route - Update Data Flow pipeline to show service-based matching, stable downloadId priority, deduplication, unmatched-torrent exclusion - Update Key Subsystems: Vite+ES module frontend, client/src tree, CSP compliance, PDCA client fix callouts - Update Directory Structure with services/, client/src/, tests/frontend/ - Update Technology Stack with jsdom and Vite build notes - Update webhook replay protection and PALDRA pagination details
This commit is contained in:
+166
-52
@@ -37,6 +37,7 @@ Three pluggable layers form the architectural core:
|
|||||||
|-------|------|----------|
|
|-------|------|----------|
|
||||||
| Download client abstraction | **PDCA** — Pluggable Download Client Architecture | `server/clients/` + `server/utils/downloadClients.js` |
|
| Download client abstraction | **PDCA** — Pluggable Download Client Architecture | `server/clients/` + `server/utils/downloadClients.js` |
|
||||||
| *arr data retrieval | **PALDRA** — Pluggable *Arr Library Data Retrieval Architecture | `server/utils/arrRetrievers.js` |
|
| *arr data retrieval | **PALDRA** — Pluggable *Arr Library Data Retrieval Architecture | `server/utils/arrRetrievers.js` |
|
||||||
|
| Download matching & assembly | **Download Services** | `server/services/` |
|
||||||
| Real-time push | **Webhook Receiver** | `server/routes/webhook.js` |
|
| Real-time push | **Webhook Receiver** | `server/routes/webhook.js` |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -50,6 +51,7 @@ flowchart TB
|
|||||||
dash["Dashboard Cards"]
|
dash["Dashboard Cards"]
|
||||||
status["Status Panel\n(Admin only)"]
|
status["Status Panel\n(Admin only)"]
|
||||||
history["History Tab"]
|
history["History Tab"]
|
||||||
|
webhooks["Webhook Config"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Server["Express Server (:3001)"]
|
subgraph Server["Express Server (:3001)"]
|
||||||
@@ -57,6 +59,7 @@ flowchart TB
|
|||||||
mw["Middleware\nHelmet · rate-limit · cookie-parser · verifyCsrf"]
|
mw["Middleware\nHelmet · rate-limit · cookie-parser · verifyCsrf"]
|
||||||
auth_r["Auth Routes\n/api/auth"]
|
auth_r["Auth Routes\n/api/auth"]
|
||||||
dash_r["Dashboard Routes\n/api/dashboard (SSE /stream)"]
|
dash_r["Dashboard Routes\n/api/dashboard (SSE /stream)"]
|
||||||
|
stat_r["Status Routes\n/api/status"]
|
||||||
wh_r["Webhook Routes\n/api/webhook/sonarr|radarr"]
|
wh_r["Webhook Routes\n/api/webhook/sonarr|radarr"]
|
||||||
hist_r["History Routes\n/api/history"]
|
hist_r["History Routes\n/api/history"]
|
||||||
proxy_r["Proxy Routes\n/api/sonarr · /api/radarr\n/api/sabnzbd · /api/emby"]
|
proxy_r["Proxy Routes\n/api/sonarr · /api/radarr\n/api/sabnzbd · /api/emby"]
|
||||||
@@ -82,7 +85,7 @@ flowchart TB
|
|||||||
|
|
||||||
login -->|"POST /api/auth/login"| auth_r
|
login -->|"POST /api/auth/login"| auth_r
|
||||||
dash -->|"GET /api/dashboard/stream (SSE)"| dash_r
|
dash -->|"GET /api/dashboard/stream (SSE)"| dash_r
|
||||||
status -->|"GET /api/dashboard/status"| dash_r
|
status -->|"GET /api/status/status"| stat_r
|
||||||
history -->|"GET /api/history/recent"| hist_r
|
history -->|"GET /api/history/recent"| hist_r
|
||||||
|
|
||||||
auth_r --> tokenstore
|
auth_r --> tokenstore
|
||||||
@@ -90,6 +93,7 @@ flowchart TB
|
|||||||
|
|
||||||
dash_r --> cache
|
dash_r --> cache
|
||||||
dash_r --> poller
|
dash_r --> poller
|
||||||
|
stat_r --> cache
|
||||||
wh_r --> cache
|
wh_r --> cache
|
||||||
wh_r --> paldra
|
wh_r --> paldra
|
||||||
hist_r --> cache
|
hist_r --> cache
|
||||||
@@ -121,10 +125,11 @@ Express Server (:3001)
|
|||||||
├── /api/auth → login, logout, me, csrf
|
├── /api/auth → login, logout, me, csrf
|
||||||
├── /api/webhook → [rate-limit] → [secret validation] → [payload validation]
|
├── /api/webhook → [rate-limit] → [secret validation] → [payload validation]
|
||||||
│ → [replay check] → updateWebhookMetrics → processWebhookEvent
|
│ → [replay check] → updateWebhookMetrics → processWebhookEvent
|
||||||
├── /api/dashboard → requireAuth → read cache → match downloads → SSE/JSON
|
├── /api/dashboard → requireAuth → read cache → DownloadBuilder → SSE/JSON
|
||||||
├── /api/history → requireAuth → historyFetcher (5 min cache) → filter + dedup
|
├── /api/status → requireAuth → admin cache/polling/webhook status
|
||||||
├── /api/sonarr|radarr → requireAuth → verifyCsrf → proxy to *arr API
|
├── /api/history → requireAuth → historyFetcher (5 min cache) → filter + dedup
|
||||||
└── /api/sabnzbd|emby → requireAuth → verifyCsrf → proxy
|
├── /api/sonarr|radarr → requireAuth → verifyCsrf → proxy to *arr API
|
||||||
|
└── /api/sabnzbd|emby → requireAuth → verifyCsrf → proxy
|
||||||
|
|
||||||
Background:
|
Background:
|
||||||
Poller (setInterval POLL_INTERVAL ms)
|
Poller (setInterval POLL_INTERVAL ms)
|
||||||
@@ -239,7 +244,7 @@ Each `QBittorrentClient` instance maintains:
|
|||||||
|
|
||||||
Per-cycle flow:
|
Per-cycle flow:
|
||||||
1. Attempt `GET /api/v2/sync/maindata?rid={lastRid}`.
|
1. Attempt `GET /api/v2/sync/maindata?rid={lastRid}`.
|
||||||
2. If `full_update` is `true`, rebuild `torrentMap` from scratch.
|
2. If `full_update` is `true`, rebuild `torrentMap` from scratch (resets incremental state to prevent corruption).
|
||||||
3. Otherwise merge delta fields into existing entries; remove `torrents_removed` hashes.
|
3. Otherwise merge delta fields into existing entries; remove `torrents_removed` hashes.
|
||||||
4. On Sync API failure, fall back **once per cycle** to `GET /api/v2/torrents/info`.
|
4. On Sync API failure, fall back **once per cycle** to `GET /api/v2/torrents/info`.
|
||||||
5. If the fallback also fails, return an empty array for this cycle and log the error.
|
5. If the fallback also fails, return an empty array for this cycle and log the error.
|
||||||
@@ -262,6 +267,10 @@ The rest of the application (poller, dashboard) receives data in the same format
|
|||||||
|
|
||||||
The registry is used by both the background poller and the webhook processor, guaranteeing consistent data shapes across both update paths.
|
The registry is used by both the background poller and the webhook processor, guaranteeing consistent data shapes across both update paths.
|
||||||
|
|
||||||
|
#### Error handling
|
||||||
|
|
||||||
|
Retriever methods now throw on HTTP failure rather than swallowing errors silently. This ensures the poller logs upstream problems and skips the affected instance cleanly instead of caching stale empty data.
|
||||||
|
|
||||||
#### Registry API
|
#### Registry API
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@@ -285,13 +294,55 @@ Each result element is `{ instance: instanceId, data: <arr API response> }`, all
|
|||||||
| Task | Endpoint | Key Parameters |
|
| Task | Endpoint | Key Parameters |
|
||||||
|------|----------|----------------|
|
|------|----------|----------------|
|
||||||
| Sonarr tags | `GET /api/v3/tag` | — |
|
| Sonarr tags | `GET /api/v3/tag` | — |
|
||||||
| Sonarr queue | `GET /api/v3/queue` | `includeSeries=true`, `includeEpisode=true` |
|
| Sonarr queue | `GET /api/v3/queue` | `includeSeries=true`, `includeEpisode=true`, `pageSize=1000` (paginated up to 50 pages) |
|
||||||
| Sonarr history | `GET /api/v3/history` | `pageSize=10`, `includeEpisode=true` |
|
| Sonarr history | `GET /api/v3/history` | `pageSize=50` (poller), `maxPages=1` (default), `includeEpisode=true` |
|
||||||
| Radarr tags | `GET /api/v3/tag` | — |
|
| Radarr tags | `GET /api/v3/tag` | — |
|
||||||
| Radarr queue | `GET /api/v3/queue` | `includeMovie=true` |
|
| Radarr queue | `GET /api/v3/queue` | `includeMovie=true`, `pageSize=1000` (paginated up to 50 pages) |
|
||||||
| Radarr history | `GET /api/v3/history` | `pageSize=10` |
|
| Radarr history | `GET /api/v3/history` | `pageSize=50` (poller), `maxPages=1` (default) |
|
||||||
|
|
||||||
All fetches across all instances run in parallel via `Promise.allSettled`, so a single failing instance does not block others.
|
All fetches across all instances run in parallel via `Promise.allSettled`, so a single failing instance does not block others. Queue fetches automatically paginate through all available records; history fetches default to a single page to avoid multi-second response times, while the UI history fetcher uses 100 records per page.
|
||||||
|
|
||||||
|
### 3.3 Download Matching & Assembly Services
|
||||||
|
|
||||||
|
#### Overview
|
||||||
|
|
||||||
|
The `server/services/` directory contains pure, testable services that transform raw cache data into user-facing download objects. These were extracted from `dashboard.js` during the technical-debt remediation, reducing the route file from ~1,360 lines to ~284 lines.
|
||||||
|
|
||||||
|
#### Service hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
DownloadBuilder.js Orchestrator: reads cache snapshot, calls matchers, deduplicates results
|
||||||
|
├── DownloadMatcher.js Matches SABnzbd slots and qBittorrent torrents to Sonarr/Radarr records
|
||||||
|
│ ├── matchSabSlots() Queue slots: matches by downloadId first, then bidirectional title substring
|
||||||
|
│ ├── matchSabHistory() History slots: title matching against Sonarr/Radarr history
|
||||||
|
│ └── matchTorrents() Torrents: queue → history fallback; unmatched torrents are excluded
|
||||||
|
├── DownloadAssembler.js Pure helpers for building download objects
|
||||||
|
│ ├── getCoverArt() Poster/fanart resolution
|
||||||
|
│ ├── getImportIssues() Warning/error message extraction
|
||||||
|
│ ├── getSonarrLink() / getRadarrLink()
|
||||||
|
│ ├── canBlocklist() Admin vs non-admin blocklist eligibility
|
||||||
|
│ ├── extractEpisode() Season/episode/title from queue/history record
|
||||||
|
│ └── gatherEpisodes() Collect all episodes sharing the same download title
|
||||||
|
└── TagMatcher.js Tag extraction, sanitisation, and user matching
|
||||||
|
├── extractAllTags() / extractUserTag()
|
||||||
|
├── tagMatchesUser() Exact or sanitised match (handles Ombi-mangled tags)
|
||||||
|
├── getEmbyUsers() Cached Emby user Map (60 s TTL)
|
||||||
|
└── buildTagBadges() Classify tags for admin showAll view
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Matching priority
|
||||||
|
|
||||||
|
For each download item, the matcher attempts matches in priority order:
|
||||||
|
|
||||||
|
1. **Stable ID match** — `downloadId` on SABnzbd slots is compared against Sonarr/Radarr queue/history `downloadId` fields (most reliable).
|
||||||
|
2. **Title substring match** — bidirectional, case-insensitive substring check between the download name and the *arr `title` / `sourceTitle`.
|
||||||
|
3. **Normalised title match** — dots replaced with spaces to handle release-name vs display-title mismatches.
|
||||||
|
|
||||||
|
Unmatched torrents are **not** included in the response (fixed in develop-refactor2).
|
||||||
|
|
||||||
|
#### Deduplication
|
||||||
|
|
||||||
|
`DownloadBuilder.buildUserDownloads()` deduplicates by `${type}:${title}` so the same download does not appear twice when it is present in both queue and history.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -341,6 +392,10 @@ Sonarr/Radarr
|
|||||||
└── pollAllServices() → pollSubscribers.forEach(cb) → SSE push
|
└── pollAllServices() → pollSubscribers.forEach(cb) → SSE push
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Replay protection improvements
|
||||||
|
|
||||||
|
The replay cache uses an atomic `Set`-based deduplication key (`{eventType}:{instanceName}:{date}`) with a 5-minute TTL. `instanceName` precision was tightened so that events from different *arr instances are never incorrectly flagged as duplicates.
|
||||||
|
|
||||||
The 200 response is sent **before** the background cache refresh completes, ensuring Sonarr/Radarr never have to wait for sofarr's downstream API calls.
|
The 200 response is sent **before** the background cache refresh completes, ensuring Sonarr/Radarr never have to wait for sofarr's downstream API calls.
|
||||||
|
|
||||||
#### Event Classification
|
#### Event Classification
|
||||||
@@ -481,25 +536,36 @@ For each connected user the server:
|
|||||||
|
|
||||||
1. Reads all `poll:*` keys from `MemoryCache`.
|
1. Reads all `poll:*` keys from `MemoryCache`.
|
||||||
2. Builds `seriesMap`, `moviesMap`, `sonarrTagMap`, and `radarrTagMap` from embedded objects in queue records.
|
2. Builds `seriesMap`, `moviesMap`, `sonarrTagMap`, and `radarrTagMap` from embedded objects in queue records.
|
||||||
3. For each SABnzbd/qBittorrent download item, attempts matches in priority order: Sonarr queue → Radarr queue → Sonarr history → Radarr history.
|
3. Delegates to `DownloadBuilder.buildUserDownloads(cacheSnapshot, options)`, which orchestrates:
|
||||||
4. Title matching is a **bidirectional, case-insensitive substring match**: `rTitle.includes(dlTitle) || dlTitle.includes(rTitle)`.
|
- `DownloadMatcher.matchSabSlots()` — matches active SABnzbd queue slots
|
||||||
5. For each match, resolves the series/movie, extracts user tags, checks ownership.
|
- `DownloadMatcher.matchSabHistory()` — matches recent SABnzbd history slots
|
||||||
6. Returns only the requesting user's downloads (or all, if admin with `showAll=true`).
|
- `DownloadMatcher.matchTorrents()` — matches qBittorrent torrents
|
||||||
|
4. Each matcher attempts matches in priority order:
|
||||||
|
- **Stable ID match** — `downloadId` compared against *arr `downloadId` (most reliable).
|
||||||
|
- **Bidirectional title substring match** — case-insensitive `rTitle.includes(dlTitle) || dlTitle.includes(rTitle)`.
|
||||||
|
- **Normalised title match** — dots replaced with spaces for release-name vs display-title mismatches.
|
||||||
|
5. Unmatched torrents are excluded; matched results are deduplicated by `${type}:${title}`.
|
||||||
|
6. For each match, `DownloadAssembler` resolves cover art, episodes, import issues, blocklist eligibility, and admin fields.
|
||||||
|
7. `TagMatcher` extracts user tags and checks ownership.
|
||||||
|
8. Returns only the requesting user's downloads (or all, if admin with `showAll=true`).
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
Start(["Download item"]) --> SQ{"Sonarr QUEUE\nmatch (title)"}
|
Start(["Download item"]) --> DLID{"Stable ID match\ndownloadId?"}
|
||||||
|
DLID -->|yes| IDResolve["Resolve series/movie\nfrom queue/history record"]
|
||||||
|
DLID -->|no| SQ{"Sonarr QUEUE\ntitle match?"}
|
||||||
SQ -->|yes| SQR["Resolve series · extract user tag"]
|
SQ -->|yes| SQR["Resolve series · extract user tag"]
|
||||||
SQ -->|no| RQ{"Radarr QUEUE\nmatch (title)"}
|
SQ -->|no| RQ{"Radarr QUEUE\ntitle match?"}
|
||||||
RQ -->|yes| RQR["Resolve movie · extract user tag"]
|
RQ -->|yes| RQR["Resolve movie · extract user tag"]
|
||||||
RQ -->|no| SH{"Sonarr HISTORY\nmatch (title)"}
|
RQ -->|no| SH{"Sonarr HISTORY\ntitle match?"}
|
||||||
SH -->|yes| SHR["Resolve series via seriesId"]
|
SH -->|yes| SHR["Resolve series via seriesId"]
|
||||||
SH -->|no| RH{"Radarr HISTORY\nmatch (title)"}
|
SH -->|no| RH{"Radarr HISTORY\ntitle match?"}
|
||||||
RH -->|yes| RHR["Resolve movie via movieId"]
|
RH -->|yes| RHR["Resolve movie via movieId"]
|
||||||
RH -->|no| Skip(["Skip — unmatched"])
|
RH -->|no| Skip(["Skip — unmatched"])
|
||||||
|
|
||||||
SQR & RQR & SHR & RHR --> Tagged{"Tag matches\nrequesting user?"}
|
IDResolve & SQR & RQR & SHR & RHR --> Tagged{"Tag matches\nrequesting user?"}
|
||||||
Tagged -->|yes| Include(["Include in response"])
|
Tagged -->|yes| Dedup["Deduplicate by type:title"]
|
||||||
|
Dedup --> Include(["Include in response"])
|
||||||
Tagged -->|no| Skip
|
Tagged -->|no| Skip
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -642,8 +708,8 @@ See [Section 3.1](#31-pluggable-download-client-architecture-pdca) for full deta
|
|||||||
|
|
||||||
```
|
```
|
||||||
DownloadClient (abstract — server/clients/DownloadClient.js)
|
DownloadClient (abstract — server/clients/DownloadClient.js)
|
||||||
├── SABnzbdClient.js — Usenet; REST; API key auth
|
├── SABnzbdClient.js — Usenet; REST; API key auth; fixed global-speed assignment
|
||||||
├── QBittorrentClient.js — Torrent; Sync API + fallback; cookie auth
|
├── QBittorrentClient.js — Torrent; Sync API + fallback; cookie auth; full-sync corruption fix
|
||||||
├── TransmissionClient.js — Torrent; JSON-RPC; session-ID management
|
├── TransmissionClient.js — Torrent; JSON-RPC; session-ID management
|
||||||
└── RTorrentClient.js — Torrent; XML-RPC; HTTP Basic Auth
|
└── RTorrentClient.js — Torrent; XML-RPC; HTTP Basic Auth
|
||||||
```
|
```
|
||||||
@@ -660,12 +726,34 @@ DownloadClient (abstract — server/clients/DownloadClient.js)
|
|||||||
|
|
||||||
### 7.3 Dashboard & Frontend
|
### 7.3 Dashboard & Frontend
|
||||||
|
|
||||||
The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `public/app.js`, styled by `public/style.css`, and structured by `public/index.html`. Three CSS themes are available via the `data-theme` attribute on `<html>` and persist in `localStorage`:
|
The frontend is a **vanilla JavaScript SPA** built from ES modules in `client/src/` and bundled by **Vite** into `public/app.js`. Three CSS themes are available via the `data-theme` attribute on `<html>` and persist in `localStorage`:
|
||||||
|
|
||||||
- **Light** — Purple gradient header, white cards
|
- **Light** — Purple gradient header, white cards
|
||||||
- **Dark** — Dark surfaces, muted accents
|
- **Dark** — Dark surfaces, muted accents
|
||||||
- **Mono** — Monochrome, minimal colour
|
- **Mono** — Monochrome, minimal colour
|
||||||
|
|
||||||
|
#### Module structure
|
||||||
|
|
||||||
|
```
|
||||||
|
client/src/
|
||||||
|
├── main.js Bootstrap: DOMContentLoaded → init theme, auth check
|
||||||
|
├── state.js Global reactive state object
|
||||||
|
├── api.js All HTTP fetch wrappers (+ CSRF handling)
|
||||||
|
├── sse.js EventSource lifecycle, reconnect, heartbeat
|
||||||
|
├── ui/
|
||||||
|
│ ├── auth.js Login/logout form handlers
|
||||||
|
│ ├── downloads.js Card rendering, create/update helpers, client-logo helpers
|
||||||
|
│ ├── filters.js Download-client multi-select filter
|
||||||
|
│ ├── history.js History tab: fetch, render, ignoreAvailable toggle
|
||||||
|
│ ├── statusPanel.js Admin status panel (server, polling, cache, webhooks)
|
||||||
|
│ ├── tabs.js Tab navigation (data-tab attributes)
|
||||||
|
│ ├── theme.js Light/Dark/Mono theme switcher
|
||||||
|
│ └── webhooks.js One-click Sonarr/Radarr webhook configuration
|
||||||
|
└── utils/
|
||||||
|
├── format.js Size, speed, duration, percentage formatters
|
||||||
|
└── storage.js localStorage wrappers with JSON parsing
|
||||||
|
```
|
||||||
|
|
||||||
#### UI state machine
|
#### UI state machine
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -701,28 +789,24 @@ stateDiagram-v2
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Key frontend functions
|
#### Key frontend modules
|
||||||
|
|
||||||
| Function | Purpose |
|
| Module / Function | Purpose |
|
||||||
|----------|---------|
|
|-------------------|---------|
|
||||||
| `checkAuthentication()` | On load: check session → show dashboard or login |
|
| `auth.js` | `checkAuthentication()`, `handleLogin()`, `handleLogout()` |
|
||||||
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
|
| `sse.js` | `startSSE()`, `stopSSE()` — EventSource lifecycle and auto-reconnect |
|
||||||
| `startSSE()` | Open `EventSource` to `/stream`; handle incoming data |
|
| `downloads.js` | `renderDownloads()`, `createDownloadCard()`, `updateDownloadCard()` — diff-based DOM; client-logo and tag-badge helpers deduplicated |
|
||||||
| `stopSSE()` | Close `EventSource` and cancel reconnect timer |
|
| `filters.js` | `initDownloadClientFilter()` — multi-select dropdown, Select/Deselect All, localStorage persistence |
|
||||||
| `renderDownloads()` | Diff-based card rendering (create/update/remove); filters by selected download clients; sorts by client order |
|
| `history.js` | `loadHistory()`, `renderHistory()` — filter by `ignoreAvailable`, render cards |
|
||||||
| `createDownloadCard()` | Build DOM for a single card; renders tag badges, import-issue badge, blocklist button |
|
| `statusPanel.js` | `toggleStatusPanel()`, `renderStatusPanel()` — admin server/polling/cache/webhook status |
|
||||||
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
|
| `theme.js` | `initThemeSwitcher()` — Light / Dark / Mono theme support |
|
||||||
| `handleBlocklistSearch()` | Confirm dialog → POST `/blocklist-search` → update button state |
|
| `webhooks.js` | One-click Sonarr/Radarr webhook configuration via proxy API |
|
||||||
| `toggleStatusPanel()` | Show/hide admin status panel |
|
| `format.js` | Size, speed, duration, percentage formatters (24 unit tests) |
|
||||||
| `renderStatusPanel()` | Build status HTML (server, polling, SSE clients, cache) |
|
| `storage.js` | localStorage wrappers with JSON parsing and error handling |
|
||||||
| `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 |
|
|
||||||
|
|
||||||
#### Tag badge rendering
|
#### CSP compliance
|
||||||
|
|
||||||
- **Regular user view** — a single accent-coloured badge showing the tag label that matched the current user's username (via `matchedUserTag`).
|
All UI modules use CSS class toggling (`.hidden`) instead of inline `style.display` to comply with the strict Content-Security-Policy enforced by Helmet.
|
||||||
- **Admin `showAll` view** — all tags on the download are rendered using `tagBadges[]`: tags with no matching Emby user → amber badge (leftmost); tags matched to a known Emby user → accent badge showing the Emby display name (rightmost).
|
|
||||||
|
|
||||||
#### Download Client Filter
|
#### Download Client Filter
|
||||||
|
|
||||||
@@ -732,14 +816,14 @@ The Active Downloads tab includes a multi-select dropdown filter that allows use
|
|||||||
- Use "Select All" / "Deselect All" buttons for bulk operations
|
- Use "Select All" / "Deselect All" buttons for bulk operations
|
||||||
- Persist selection across sessions via localStorage
|
- Persist selection across sessions via localStorage
|
||||||
|
|
||||||
Downloads are sorted by client order (matching the configuration order) and filtered by the selected client IDs.
|
Related functions in `filters.js`:
|
||||||
|
|
||||||
Related functions:
|
|
||||||
- `initDownloadClientFilter()` — Sets up dropdown toggle, click-outside handler, Select/Deselect All buttons
|
- `initDownloadClientFilter()` — Sets up dropdown toggle, click-outside handler, Select/Deselect All buttons
|
||||||
- `updateDownloadClientFilter()` — Populates checkbox list with client name + type badges
|
- `updateDownloadClientFilter()` — Populates checkbox list with client name + type badges
|
||||||
- `toggleClientSelection()` — Updates selection array and localStorage
|
- `toggleClientSelection()` — Updates selection array and localStorage
|
||||||
- `updateSelectedCountDisplay()` — Updates button text to show "All clients" / "1 selected" / "N selected"
|
- `updateSelectedCountDisplay()` — Updates button text to show "All clients" / "1 selected" / "N selected"
|
||||||
|
|
||||||
|
Downloads are sorted by client order (matching the configuration order) and filtered by the selected client IDs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Directory Structure
|
## 8. Directory Structure
|
||||||
@@ -759,7 +843,8 @@ sofarr/
|
|||||||
│ │ └── PollingRadarrRetriever.js PALDRA — Radarr retriever
|
│ │ └── PollingRadarrRetriever.js PALDRA — Radarr retriever
|
||||||
│ ├── routes/
|
│ ├── routes/
|
||||||
│ │ ├── auth.js POST /login, GET /me, GET /csrf, POST /logout
|
│ │ ├── auth.js POST /login, GET /me, GET /csrf, POST /logout
|
||||||
│ │ ├── dashboard.js SSE /stream, /user-downloads, /user-summary, /status, /cover-art, /blocklist-search
|
│ │ ├── dashboard.js SSE /stream, /user-downloads, /blocklist-search
|
||||||
|
│ │ ├── status.js GET /api/status/status — admin server/polling/webhook status
|
||||||
│ │ ├── history.js GET /api/history/recent
|
│ │ ├── history.js GET /api/history/recent
|
||||||
│ │ ├── webhook.js POST /api/webhook/sonarr|radarr
|
│ │ ├── webhook.js POST /api/webhook/sonarr|radarr
|
||||||
│ │ ├── sonarr.js Sonarr API proxy + webhook management
|
│ │ ├── sonarr.js Sonarr API proxy + webhook management
|
||||||
@@ -769,6 +854,12 @@ sofarr/
|
|||||||
│ ├── middleware/
|
│ ├── middleware/
|
||||||
│ │ ├── requireAuth.js httpOnly cookie auth enforcement
|
│ │ ├── requireAuth.js httpOnly cookie auth enforcement
|
||||||
│ │ └── verifyCsrf.js Double-submit CSRF check (timing-safe)
|
│ │ └── verifyCsrf.js Double-submit CSRF check (timing-safe)
|
||||||
|
│ ├── services/ Download matching & assembly services
|
||||||
|
│ │ ├── DownloadBuilder.js Orchestrator: cache snapshot → matched downloads
|
||||||
|
│ │ ├── DownloadMatcher.js Match SABnzbd/qBittorrent to *arr records
|
||||||
|
│ │ ├── DownloadAssembler.js Pure helpers: cover art, links, episodes, blocklist
|
||||||
|
│ │ ├── TagMatcher.js Tag extraction, sanitisation, user matching
|
||||||
|
│ │ └── WebhookStatus.js Webhook configuration check + metrics aggregation
|
||||||
│ └── utils/
|
│ └── utils/
|
||||||
│ ├── arrRetrievers.js PALDRA registry — Sonarr/Radarr fetch registry
|
│ ├── arrRetrievers.js PALDRA registry — Sonarr/Radarr fetch registry
|
||||||
│ ├── cache.js MemoryCache + webhook metrics helpers
|
│ ├── cache.js MemoryCache + webhook metrics helpers
|
||||||
@@ -780,15 +871,37 @@ sofarr/
|
|||||||
│ ├── qbittorrent.js Legacy compatibility shim → QBittorrentClient
|
│ ├── qbittorrent.js Legacy compatibility shim → QBittorrentClient
|
||||||
│ ├── sanitizeError.js Secret redaction from errors/logs
|
│ ├── sanitizeError.js Secret redaction from errors/logs
|
||||||
│ └── tokenStore.js Emby token store (JSON file, atomic writes, 31-day TTL)
|
│ └── tokenStore.js Emby token store (JSON file, atomic writes, 31-day TTL)
|
||||||
├── public/ Static SPA (served by Express)
|
├── client/ Frontend source (vanilla ES modules)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.js Bootstrap entry point
|
||||||
|
│ │ ├── state.js Global reactive state
|
||||||
|
│ │ ├── api.js HTTP fetch wrappers
|
||||||
|
│ │ ├── sse.js EventSource management
|
||||||
|
│ │ ├── ui/
|
||||||
|
│ │ │ ├── auth.js
|
||||||
|
│ │ │ ├── downloads.js
|
||||||
|
│ │ │ ├── filters.js
|
||||||
|
│ │ │ ├── history.js
|
||||||
|
│ │ │ ├── statusPanel.js
|
||||||
|
│ │ │ ├── tabs.js
|
||||||
|
│ │ │ ├── theme.js
|
||||||
|
│ │ │ └── webhooks.js
|
||||||
|
│ │ └── utils/
|
||||||
|
│ │ ├── format.js
|
||||||
|
│ │ └── storage.js
|
||||||
|
│ ├── index.html Development HTML shell
|
||||||
|
│ ├── package.json Frontend dev dependencies (vite)
|
||||||
|
│ └── vite.config.js Build config → ../public/app.js
|
||||||
|
├── public/ Static SPA assets (served by Express)
|
||||||
│ ├── index.html HTML shell: splash, login, dashboard
|
│ ├── index.html HTML shell: splash, login, dashboard
|
||||||
│ ├── app.js All frontend logic
|
│ ├── app.js Bundled frontend (Vite build output)
|
||||||
│ ├── style.css Themes, layout, responsive design
|
│ ├── style.css Themes, layout, responsive design
|
||||||
│ ├── favicon.ico / *.png Favicons
|
│ ├── favicon.ico / *.png Favicons
|
||||||
│ └── images/ Logo / splash screen assets
|
│ └── images/ Logo / splash screen assets
|
||||||
├── tests/
|
├── tests/
|
||||||
│ ├── README.md Testing approach and coverage targets
|
│ ├── README.md Testing approach and coverage targets
|
||||||
│ ├── setup.js Global setup: isolated DATA_DIR, rate-limit bypass
|
│ ├── setup.js Global setup: isolated DATA_DIR, rate-limit bypass
|
||||||
|
│ ├── frontend/ Vitest + jsdom unit tests for client/src
|
||||||
│ ├── unit/ Pure unit tests (no HTTP)
|
│ ├── unit/ Pure unit tests (no HTTP)
|
||||||
│ └── integration/ Supertest + nock integration tests
|
│ └── integration/ Supertest + nock integration tests
|
||||||
├── .gitea/workflows/
|
├── .gitea/workflows/
|
||||||
@@ -797,7 +910,7 @@ sofarr/
|
|||||||
│ ├── create-release.yml Release tagging workflow
|
│ ├── create-release.yml Release tagging workflow
|
||||||
│ ├── docs-check.yml Markdown lint + Mermaid validation
|
│ ├── docs-check.yml Markdown lint + Mermaid validation
|
||||||
│ └── licence-check.yml Production dependency licence check
|
│ └── licence-check.yml Production dependency licence check
|
||||||
├── Dockerfile Multi-stage production image (node:22-alpine)
|
├── Dockerfile Multi-stage production image (node:22-alpine) — includes Vite client build stage
|
||||||
├── docker-compose.yaml Example compose deployment
|
├── docker-compose.yaml Example compose deployment
|
||||||
├── vitest.config.js Test runner configuration with per-file coverage thresholds
|
├── vitest.config.js Test runner configuration with per-file coverage thresholds
|
||||||
├── package.json Dependencies and scripts
|
├── package.json Dependencies and scripts
|
||||||
@@ -929,7 +1042,7 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
|||||||
| Framework | Express 4.x | HTTP server, routing, middleware |
|
| Framework | Express 4.x | HTTP server, routing, middleware |
|
||||||
| HTTP client | axios 1.x | External API communication |
|
| HTTP client | axios 1.x | External API communication |
|
||||||
| XML-RPC client | xmlrpc 1.3.2 | rTorrent communication |
|
| XML-RPC client | xmlrpc 1.3.2 | rTorrent communication |
|
||||||
| Frontend | Vanilla JS + CSS | SPA, no build step required |
|
| Frontend | Vanilla JS + CSS | SPA; Vite bundles ES modules from `client/src/` into `public/app.js` |
|
||||||
| Containerisation | Docker multi-stage (node:22-alpine) | Non-root `node` user; minimal image |
|
| Containerisation | Docker multi-stage (node:22-alpine) | Non-root `node` user; minimal image |
|
||||||
| Logging | Custom logger + `console.*` redirection | File + stdout with configurable levels |
|
| Logging | Custom logger + `console.*` redirection | File + stdout with configurable levels |
|
||||||
|
|
||||||
@@ -957,12 +1070,13 @@ Each instance receives an `id` derived from `name` (or index if unnamed), used a
|
|||||||
| `vitest` | 4.x | Test runner with V8 coverage; per-file coverage thresholds in `vitest.config.js` |
|
| `vitest` | 4.x | Test runner with V8 coverage; per-file coverage thresholds in `vitest.config.js` |
|
||||||
| `supertest` | 7.x | HTTP integration testing against the Express app factory |
|
| `supertest` | 7.x | HTTP integration testing against the Express app factory |
|
||||||
| `nock` | 14.x | HTTP interception at Node layer (compatible with CJS `require('axios')`) |
|
| `nock` | 14.x | HTTP interception at Node layer (compatible with CJS `require('axios')`) |
|
||||||
|
| `jsdom` | 24.x | Browser-like DOM environment for frontend unit tests |
|
||||||
|
|
||||||
### CI/CD
|
### CI/CD
|
||||||
|
|
||||||
| Workflow file | Trigger | Purpose |
|
| Workflow file | Trigger | Purpose |
|
||||||
|---------------|---------|---------|
|
|---------------|---------|---------|
|
||||||
| `ci.yml` | Every push / PR | `npm audit --audit-level=high` + full test suite with V8 coverage |
|
| `ci.yml` | Every push / PR | `npm audit --audit-level=high` + full test suite (unit, integration, frontend) with V8 coverage |
|
||||||
| `build-image.yml` | Push to `release/**` or `develop` | Build and push Docker image to registry |
|
| `build-image.yml` | Push to `release/**` or `develop` | Build and push Docker image to registry |
|
||||||
| `create-release.yml` | Tag push (`v*`) | Generate release notes and create a Gitea release |
|
| `create-release.yml` | Tag push (`v*`) | Generate release notes and create a Gitea release |
|
||||||
| `docs-check.yml` | Push / PR touching `**.md` | Markdown lint + Mermaid diagram parse validation |
|
| `docs-check.yml` | Push / PR touching `**.md` | Markdown lint + Mermaid diagram parse validation |
|
||||||
|
|||||||
Reference in New Issue
Block a user