Diagrams etc. (#5)
All checks were successful
CI / Security audit (push) Successful in 50s
CI / Tests & coverage (push) Successful in 57s

Co-authored-by: Gronod <gordon@i3omb.com>
Co-authored-by: gitea-actions[bot] <gitea-actions[bot]@i3omb.com>
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2026-05-17 10:47:50 +01:00
parent 29d7bdb536
commit 5d7b126c5e
20 changed files with 602 additions and 1244 deletions

View File

@@ -18,7 +18,7 @@ Comprehensive technical documentation covering the full architecture of the **so
10. [Frontend Architecture](#10-frontend-architecture)
11. [Configuration](#11-configuration)
12. [Deployment](#12-deployment)
13. [UML Diagrams (PlantUML)](#13-uml-diagrams-plantuml)
13. [Diagrams (Mermaid)](#13-diagrams)
---
@@ -775,32 +775,618 @@ The `.gitea/workflows/` directory contains three pipeline definitions:
| `build-image.yml` | Push to `main` / `develop` | Build and push Docker image to `docker.i3omb.com` |
| `create-release.yml` | Tag push (`v*`) | Create a Gitea release |
> **Diagrams** are written in Mermaid and render natively in Gitea — no CI workflow required. See [Section 13](#13-diagrams).
---
## 13. UML Diagrams (PlantUML)
## 13. Diagrams
All diagrams below are in PlantUML syntax. Render with any PlantUML tool or VS Code extension.
All diagrams are written in [Mermaid](https://mermaid.js.org/) and render natively in Gitea and GitHub markdown.
### 13.1 Component Diagram
See [`diagrams/component.puml`](diagrams/component.puml)
```mermaid
graph TB
subgraph Browser
html[index.html]
appjs[app.js]
css[style.css]
html -->|loads| appjs
html -->|loads| css
end
### 13.2 Sequence Diagrams
subgraph Express Server
entry[index.js\nEntry Point]
appfactory[app.js\ncreateApp factory]
- **Authentication**: [`diagrams/seq-auth.puml`](diagrams/seq-auth.puml)
- **Dashboard Request**: [`diagrams/seq-dashboard.puml`](diagrams/seq-dashboard.puml)
- **Polling Cycle**: [`diagrams/seq-polling.puml`](diagrams/seq-polling.puml)
subgraph Middleware
hm[helmet\nCSP nonce + HSTS]
rl[express-rate-limit\nAPI + login]
cp[cookie-parser\nsigned cookies]
ej[express.json\n64kb limit]
es[express.static]
requireauth[requireAuth.js]
verifycsrf[verifyCsrf.js\ndouble-submit]
end
### 13.3 Class / Entity Diagrams
subgraph Routes
auth[auth.js\n/api/auth\npre-CSRF]
dashboard[dashboard.js\n/api/dashboard\n+SSE /stream]
emby_r[emby.js\n/api/emby]
sab_r[sabnzbd.js\n/api/sabnzbd]
sonarr_r[sonarr.js\n/api/sonarr]
radarr_r[radarr.js\n/api/radarr]
end
- **Server Classes**: [`diagrams/class-server.puml`](diagrams/class-server.puml)
- **Data Model**: [`diagrams/class-data.puml`](diagrams/class-data.puml)
subgraph Utilities
poller[poller.js]
cache[cache.js\nMemoryCache]
config[config.js]
qbt[qbittorrent.js\nQBittorrentClient]
tokenstore[tokenStore.js\ntokens.json]
sanitize[sanitizeError.js]
logger[logger.js]
end
### 13.4 State Diagrams
entry --> appfactory
entry --> es
entry --> poller
- **Frontend UI States**: [`diagrams/state-ui.puml`](diagrams/state-ui.puml)
- **Poller States**: [`diagrams/state-poller.puml`](diagrams/state-poller.puml)
appfactory --> hm & rl & cp & ej
appfactory -->|pre-CSRF| auth
appfactory --> verifycsrf
appfactory --> dashboard & emby_r & sab_r & sonarr_r & radarr_r
### 13.5 Activity Diagram
dashboard & emby_r & sab_r & sonarr_r & radarr_r --> requireauth
auth --> tokenstore
dashboard --> cache & poller & config & qbt
poller --> cache & config & qbt & logger
qbt --> config & logger
auth & dashboard -.-> sanitize
end
- **Download Matching**: [`diagrams/activity-matching.puml`](diagrams/activity-matching.puml)
subgraph External Services
emby[Emby / Jellyfin]
sab[SABnzbd]
sonarr[Sonarr]
radarr[Radarr]
qbit[qBittorrent]
end
auth --> emby
dashboard --> emby
poller --> sab & sonarr & radarr
qbt --> qbit
emby_r --> emby
sab_r --> sab
sonarr_r --> sonarr
radarr_r --> radarr
appjs -->|POST /login\nGET /me\nGET /csrf\nPOST /logout| auth
appjs -->|GET /stream SSE\nGET /user-downloads\nGET /status| dashboard
es -->|serve static| html
```
### 13.2 Authentication Sequence
```mermaid
sequenceDiagram
actor User
participant Browser as Browser (app.js)
participant Auth as Express /api/auth
participant Tokens as TokenStore (tokens.json)
participant Emby as Emby Server
rect rgb(240,240,255)
Note over Browser,Auth: Page Load
Browser->>Auth: GET /api/auth/me
Auth->>Auth: Read emby_user cookie (signed if COOKIE_SECRET)
alt Cookie valid
Auth-->>Browser: { authenticated: true, user }
Browser->>Auth: GET /api/auth/csrf
Auth-->>Browser: { csrfToken } + Set csrf_token cookie
Browser->>Browser: store csrfToken in memory
Browser->>Browser: showDashboard() + startSSE()
else No cookie / tampered
Auth-->>Browser: { authenticated: false }
Browser->>Browser: showLogin()
end
end
rect rgb(240,255,240)
Note over Browser,Emby: Login
User->>Browser: Enter credentials (+ rememberMe)
Browser->>Auth: POST /api/auth/login
Note right of Auth: Rate limit: max 10 failed\nattempts per IP / 15 min
Auth->>Emby: POST /Users/authenticatebyname\nDeviceId = sha256(username)[0:16]
alt Valid credentials
Emby-->>Auth: { User.Id, AccessToken }
Auth->>Emby: GET /Users/{id}
Emby-->>Auth: { Name, Policy.IsAdministrator }
Auth->>Tokens: storeToken(userId, AccessToken)
Note right of Tokens: Server-side only\n31-day TTL, atomic write
Auth->>Auth: Set emby_user cookie\nhttpOnly, sameSite=strict\nsecure (if TRUST_PROXY)\nrememberMe → Max-Age 30d
Auth->>Auth: Set csrf_token cookie\nhttpOnly=false, sameSite=strict
Auth-->>Browser: { success: true, user, csrfToken }
Browser->>Browser: showDashboard() + startSSE()
else Invalid credentials
Emby-->>Auth: 401
Auth-->>Browser: { success: false, error }
end
end
rect rgb(255,245,230)
Note over Browser,Auth: Logout
User->>Browser: Click Logout
Browser->>Browser: stopSSE()
Browser->>Auth: POST /api/auth/logout
Auth->>Tokens: getToken(userId)
Tokens-->>Auth: { accessToken }
Auth->>Emby: POST /Sessions/Logout
Auth->>Tokens: clearToken(userId)
Auth->>Auth: clearCookie(emby_user, csrf_token)
Auth-->>Browser: { success: true }
Browser->>Browser: showLogin()
end
```
### 13.3 Dashboard SSE Stream Sequence
```mermaid
sequenceDiagram
actor User
participant Browser as Browser (app.js)
participant Dashboard as Express /api/dashboard
participant Cache as MemoryCache
participant Poller
participant Ext as External Services
User->>Browser: Login success / valid session
Browser->>Dashboard: GET /api/dashboard/stream (EventSource)
Dashboard->>Dashboard: requireAuth: extract user/isAdmin
Dashboard->>Dashboard: Set Content-Type: text/event-stream\nRegister in activeClients
opt Polling disabled AND cache empty
Dashboard->>Poller: pollAllServices()
Poller->>Ext: Parallel API calls
Ext-->>Poller: Raw data
Poller->>Cache: set poll:* keys (TTL=30s)
end
Dashboard->>Cache: get all poll:* keys
Dashboard->>Dashboard: Build maps, match downloads\nextractUserTag / buildTagBadges
Dashboard-->>Browser: data: { user, isAdmin, downloads }
Browser->>Browser: hideLoading() + renderDownloads()
loop Every poll cycle
Poller->>Poller: pollAllServices() complete
Poller->>Dashboard: onPollComplete callback fires
Dashboard->>Cache: get all poll:* keys
Dashboard->>Dashboard: Rebuild payload
Dashboard-->>Browser: data: { user, isAdmin, downloads }
Browser->>Browser: renderDownloads() diff-based
end
Note over Dashboard,Browser: : heartbeat every 25s keeps connection alive
User->>Browser: Close tab / logout
Browser->>Dashboard: TCP close (req close event)
Dashboard->>Dashboard: offPollComplete(cb)\nclearInterval(heartbeat)\ndelete activeClients[key]
```
### 13.4 Background Polling Cycle
```mermaid
sequenceDiagram
participant Entry as index.js (startup)
participant Poller
participant Config
participant SAB as SABnzbd (per instance)
participant Sonarr as Sonarr (per instance)
participant Radarr as Radarr (per instance)
participant QBT as qBittorrent Client
participant Cache as MemoryCache
Entry->>Poller: startPoller()
alt POLL_INTERVAL > 0
Poller->>Poller: pollAllServices() immediate
Poller->>Poller: setInterval(pollAllServices, POLL_INTERVAL)
else POLL_INTERVAL = 0
Poller-->>Entry: on-demand mode
end
Note over Poller: Each poll cycle
Poller->>Poller: polling flag check (skip if concurrent)
Poller->>Poller: polling = true
Poller->>Config: getSABnzbdInstances() / getSonarrInstances() / getRadarrInstances()
Config-->>Poller: instance configs
Note over Poller,Cache: All 9 fetches run in parallel via Promise.all, each wrapped in timed()
Poller->>SAB: GET /api?mode=queue
SAB-->>Poller: { queue: { slots, status, speed } }
Poller->>SAB: GET /api?mode=history&limit=10
SAB-->>Poller: { history: { slots } }
Poller->>Sonarr: GET /api/v3/tag + queue + history
Sonarr-->>Poller: tags, queue records (includeSeries), history
Poller->>Radarr: GET /api/v3/tag + queue + history
Radarr-->>Poller: tags, queue records (includeMovie), history
Poller->>QBT: getTorrents()
QBT-->>Poller: [{ name, progress, ... }]
Poller->>Poller: Record per-task timings\nlastPollTimings = { totalMs, timestamp, tasks }
Poller->>Cache: set poll:* keys (TTL = POLL_INTERVAL × 3)
Poller->>Poller: Notify SSE subscribers (forEach cb())
Poller->>Poller: polling = false
```
### 13.5 Server Class Diagram
```mermaid
classDiagram
class EntryPoint["index.js (EntryPoint)"] {
+startPoller()
+app.listen()
setupLogging()
serveStatic()
}
class createApp["app.js (createApp factory)"] {
+createApp(skipRateLimits?) Express
mountHelmet()
mountRateLimiters()
mountRoutes()
mountErrorHandler()
}
class AuthRouter["auth.js (Router)"] {
+POST /login
+GET /me
+GET /csrf
+POST /logout
authenticateViaEmby()
issueCookies()
revokeToken()
}
class DashboardRouter["dashboard.js (Router)"] {
-activeClients Map
+GET /stream SSE
+GET /user-downloads
+GET /user-summary
+GET /status
+GET /cover-art
buildDownloadPayload()
extractUserTag()
buildTagBadges()
getEmbyUsers()
}
class RequireAuth["requireAuth.js (Middleware)"] {
+requireAuth(req, res, next)
readCookie()
validateSchema()
}
class VerifyCsrf["verifyCsrf.js (Middleware)"] {
+verifyCsrf(req, res, next)
timingSafeEqual()
}
class MemoryCache {
-store Map
+get(key) any
+set(key, value, ttlMs)
+invalidate(key)
+clear()
+getStats() CacheStats
}
class Poller {
-POLL_INTERVAL number
-polling boolean
-subscribers Set
+startPoller()
+stopPoller()
+pollAllServices()
+onPollComplete(cb)
+offPollComplete(cb)
+getLastPollTimings() PollTimings
}
class Config {
+getSABnzbdInstances() Instance[]
+getSonarrInstances() Instance[]
+getRadarrInstances() Instance[]
+getQbittorrentInstances() Instance[]
}
class QBittorrentClient {
-url string
-authCookie string
+login() bool
+getTorrents() Torrent[]
+makeRequest(endpoint)
}
class TokenStore {
-STORE_PATH string
-TOKEN_TTL_MS 31days
+storeToken(userId, token)
+getToken(userId)
+clearToken(userId)
atomicWrite()
pruneExpired()
}
class SanitizeError {
+sanitizeError(err) string
redactQueryParams()
redactAuthHeaders()
}
EntryPoint --> createApp : createApp()
EntryPoint --> Poller : startPoller()
createApp --> AuthRouter : mount pre-CSRF
createApp --> VerifyCsrf : apply to /api
createApp --> DashboardRouter
DashboardRouter --> RequireAuth
DashboardRouter --> MemoryCache
DashboardRouter --> Poller
DashboardRouter --> Config
DashboardRouter ..> SanitizeError
AuthRouter --> TokenStore
AuthRouter ..> SanitizeError
Poller --> MemoryCache
Poller --> Config
Poller --> QBittorrentClient
QBittorrentClient --> Config
```
### 13.6 Data Model Diagram
```mermaid
classDiagram
class Download {
+type series|movie|torrent
+title string
+coverArt string
+status string
+progress string
+size string
+mb string
+mbmissing string
+speed string
+eta string
+seriesName string
+movieName string
+allTags string[]
+matchedUserTag string
+tagBadges TagBadge[]
+importIssues string[]
+downloadPath string
+targetPath string
+arrLink string
+seeds number
+peers number
+availability string
+hash string
+completedAt string
}
class TagBadge {
+label string
+matchedUser string
}
class APIResponse {
+user string
+isAdmin boolean
+downloads Download[]
}
class SSEEvent {
+user string
+isAdmin boolean
+downloads Download[]
}
class StatusResponse {
+server ServerInfo
+polling PollingInfo
+cache CacheStats
+clients ClientInfo[]
}
class SessionCookie {
+id string
+name string
+isAdmin boolean
}
class SABnzbdQueueSlot {
+filename string
+percentage string
+mb string
+mbmissing string
+timeleft string
+status string
}
class qBittorrentTorrent {
+name string
+hash string
+progress float
+state string
+dlspeed number
+eta number
+num_seeds number
+num_leechs number
+availability number
}
class SonarrQueueRecord {
+seriesId number
+series SonarrSeries
+title string
+trackedDownloadStatus string
+statusMessages StatusMessage[]
}
class RadarrQueueRecord {
+movieId number
+movie RadarrMovie
+title string
+trackedDownloadStatus string
+statusMessages StatusMessage[]
}
APIResponse "1" *-- "many" Download
SSEEvent "1" *-- "many" Download
Download "1" *-- "many" TagBadge
SABnzbdQueueSlot ..> Download : matched and transformed
qBittorrentTorrent ..> Download : mapTorrentToDownload()
SonarrQueueRecord ..> Download : coverArt, seriesName, tags
RadarrQueueRecord ..> Download : coverArt, movieName, tags
```
### 13.7 Frontend UI State Diagram
```mermaid
stateDiagram-v2
[*] --> SplashScreen : Page load
SplashScreen --> CheckAuth : checkAuthentication()
state CheckAuth <<choice>>
CheckAuth --> LoginForm : No session
CheckAuth --> Dashboard : Valid session
state LoginForm {
[*] --> Idle
Idle --> Submitting : Submit form
Submitting --> Error : Auth failed
Error --> Submitting : Re-submit
Submitting --> [*] : Auth success
}
LoginForm --> Dashboard : Auth success\n(fade transition)
state Dashboard {
[*] --> Rendering
Rendering --> Rendering : SSE message → renderDownloads()
Rendering --> Rendering : Theme change
state SSEConnection {
[*] --> Connecting
Connecting --> Connected : First message
Connected --> Reconnecting : Connection lost
Reconnecting --> Connected : Auto-reconnect
Connected --> Connecting : showAll toggled
}
state StatusPanel {
[*] --> Closed
Closed --> Open : Click Status (admin)
Open --> Closed : Click close
Open --> Open : 5s timer refresh
}
}
Dashboard --> LoginForm : Logout (stopSSE)
```
### 13.8 Poller State Diagram
```mermaid
stateDiagram-v2
[*] --> CheckConfig : startPoller()
state CheckConfig <<choice>>
CheckConfig --> Disabled : POLL_INTERVAL = 0
CheckConfig --> Idle : POLL_INTERVAL > 0
state Disabled {
[*] --> OnDemand
OnDemand : No background timer.\nData fetched when dashboard\nrequest finds empty cache.
}
Disabled --> Polling : dashboard triggers pollAllServices()
Polling --> Disabled : Poll complete (on-demand)
Idle --> Polling : setInterval fires\nor immediate first poll
state Polling {
[*] --> Locked
Locked : polling = true
Locked --> Fetching
Fetching --> Storing : All promises resolved
Fetching --> HandleError : Per-service error (caught)
Storing --> Notifying : Cache updated\nTTL = POLL_INTERVAL × 3
Notifying : Notify SSE subscribers
Notifying --> Done
Done : polling = false
Done --> [*]
}
state HandleError {
[*] --> LogError
LogError : Log error, polling = false
}
Polling --> Idle : Poll complete
HandleError --> Idle : Next interval
state ConcurrentSkip {
[*] --> Skip
Skip : polling === true, skip cycle
}
Idle --> ConcurrentSkip : Interval fires while\nprevious still running
ConcurrentSkip --> Idle : Log skip
```
### 13.9 Download Matching Flow
```mermaid
flowchart TD
A([Start: user request]) --> B[Read all poll:* keys from MemoryCache]
B --> C[Build seriesMap, moviesMap\nsonarrTagMap, radarrTagMap]
C --> D{showAll?}
D -->|yes| E[Fetch Emby user list\ncached 60s → embyUserMap]
D -->|no| F
E --> F[userDownloads = empty array]
F --> G[/SABnzbd queue slots/]
G --> H{Matches Sonarr queue?}
H -->|yes| I[Resolve series\nextractAllTags + extractUserTag]
I --> J{showAll + anyTag\nor matchedUserTag?}
J -->|yes| K[Build Download object\nAdd tagBadges if showAll\nAdd importIssues, admin fields]
K --> L[Push to userDownloads]
H --> M{Matches Radarr queue?}
M -->|yes| N[Resolve movie\nextractAllTags + extractUserTag]
N --> J
L --> G
G --> O[/SABnzbd history slots/]
O --> P{Matches Sonarr history?}
P -->|yes| Q[Resolve series\nBuild Download type=series\nAdd completedAt]
Q --> R{showAll+anyTag\nor matchedUserTag?}
R -->|yes| S[Push to userDownloads]
P --> T{Matches Radarr history?}
T -->|yes| U[Resolve movie\nBuild Download type=movie\nAdd completedAt]
U --> R
S --> O
O --> V[/qBittorrent torrents/]
V --> W{Matches Sonarr queue?}
W -->|yes| X[mapTorrentToDownload\n+ enrich with series]
X --> Y{Tag matches?}
Y -->|yes| Z[Push to userDownloads]
W --> AA{Matches Radarr queue?}
AA -->|yes| AB[mapTorrentToDownload\n+ enrich with movie]
AB --> Y
AA --> AC{Matches Sonarr history?}
AC -->|yes| AD[Resolve series via seriesMap]
AD --> Y
AC --> AE{Matches Radarr history?}
AE -->|yes| AF[Resolve movie via moviesMap]
AF --> Y
AE -->|no| AG[Skip - unmatched torrent]
Z --> V
AG --> V
V --> AH([Return JSON\nuser, isAdmin, downloads])
style K fill:#d4edda
style Q fill:#d4edda
style U fill:#d4edda
style X fill:#d4edda
style AB fill:#d4edda
style AD fill:#d4edda
style AF fill:#d4edda
style AG fill:#f8d7da
```