Diagrams etc. (#5)
Co-authored-by: Gronod <gordon@i3omb.com> Co-authored-by: gitea-actions[bot] <gitea-actions[bot]@i3omb.com> Reviewed-on: #5
@@ -1,42 +0,0 @@
|
||||
name: Render PlantUML Diagrams
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "develop", "release/**"]
|
||||
paths:
|
||||
- "docs/diagrams/**.puml"
|
||||
|
||||
jobs:
|
||||
render:
|
||||
name: Render .puml → .png
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.BOT_TOKEN }}
|
||||
|
||||
- name: Install Java & Graphviz
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends default-jre-headless graphviz
|
||||
|
||||
- name: Download PlantUML jar
|
||||
run: |
|
||||
curl -sSL -o /usr/local/bin/plantuml.jar \
|
||||
https://github.com/plantuml/plantuml/releases/download/v1.2024.6/plantuml-1.2024.6.jar
|
||||
|
||||
- name: Render diagrams
|
||||
run: |
|
||||
java -jar /usr/local/bin/plantuml.jar -tpng -o . docs/diagrams/*.puml
|
||||
|
||||
- name: Commit rendered PNGs
|
||||
run: |
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions[bot]@i3omb.com"
|
||||
git add docs/diagrams/*.png
|
||||
if git diff --cached --quiet; then
|
||||
echo "No diagram changes to commit."
|
||||
else
|
||||
git commit -m "ci: render PlantUML diagrams [skip ci]"
|
||||
git push
|
||||
fi
|
||||
@@ -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
|
||||
```
|
||||
|
||||
BIN
docs/diagrams/activity-matching.png
Normal file
|
After Width: | Height: | Size: 331 KiB |
@@ -1,156 +0,0 @@
|
||||
@startuml activity-matching
|
||||
!theme plain
|
||||
title sofarr — Download Matching Activity Diagram
|
||||
|
||||
start
|
||||
|
||||
:Read cached data from MemoryCache;
|
||||
note right
|
||||
poll:sab-queue, poll:sab-history,
|
||||
poll:sonarr-queue, poll:sonarr-history,
|
||||
poll:radarr-queue, poll:radarr-history,
|
||||
poll:sonarr-tags, poll:radarr-tags,
|
||||
poll:qbittorrent
|
||||
end note
|
||||
|
||||
:Build **seriesMap** from Sonarr queue records
|
||||
(seriesId → embedded series object);
|
||||
|
||||
:Build **moviesMap** from Radarr queue records
|
||||
(movieId → embedded movie object);
|
||||
|
||||
:Build **sonarrTagMap** (tagId → label)
|
||||
Build **radarrTagMap** (tagId → label);
|
||||
|
||||
if (showAll?) then (yes)
|
||||
:Fetch full Emby user list
|
||||
Build **embyUserMap** (lowerName → displayName)
|
||||
[cached 60s];
|
||||
endif
|
||||
|
||||
:Initialise **userDownloads** = [];
|
||||
|
||||
partition "Process SABnzbd Queue Slots" {
|
||||
while (More queue slots?) is (yes)
|
||||
:Get slot filename (nzbName);
|
||||
:nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
if (Title matches Sonarr **queue** record?) then (yes)
|
||||
:series = seriesMap.get(match.seriesId)\n|| match.series;
|
||||
if (series exists?) then (yes)
|
||||
:allTags = extractAllTags(series.tags, sonarrTagMap)
|
||||
matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll AND hasAnyTag?) then (yes)
|
||||
:Build download object (type=series)
|
||||
Add coverArt, status, progress, speed, eta
|
||||
Add allTags, matchedUserTag
|
||||
Add tagBadges = buildTagBadges(allTags, embyUserMap)
|
||||
Add importIssues if any
|
||||
Add admin fields (paths, arrLink);
|
||||
:Push to **userDownloads**;
|
||||
elseif (NOT showAll AND matchedUserTag?) then (yes)
|
||||
:Build download object (type=series)
|
||||
Add matchedUserTag;
|
||||
:Push to **userDownloads**;
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
if (Title matches Radarr **queue** record?) then (yes)
|
||||
:movie = moviesMap.get(match.movieId)\n|| match.movie;
|
||||
if (movie exists?) then (yes)
|
||||
:allTags = extractAllTags(movie.tags, radarrTagMap)
|
||||
matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll AND hasAnyTag?) then (yes)
|
||||
:Build download object (type=movie)
|
||||
Add coverArt, status, progress, speed, eta
|
||||
Add allTags, matchedUserTag, tagBadges
|
||||
Add importIssues if any
|
||||
Add admin fields (paths, arrLink);
|
||||
:Push to **userDownloads**;
|
||||
elseif (NOT showAll AND matchedUserTag?) then (yes)
|
||||
:Build download object (type=movie)
|
||||
Add matchedUserTag;
|
||||
:Push to **userDownloads**;
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
endwhile (no)
|
||||
}
|
||||
|
||||
partition "Process SABnzbd History Slots" {
|
||||
while (More history slots?) is (yes)
|
||||
:Get slot name (nzbName);
|
||||
:nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
if (Title matches Sonarr **history** record?) then (yes)
|
||||
:series = seriesMap.get(match.seriesId)\n|| match.series;
|
||||
if (series found?) then (yes)
|
||||
:extractAllTags + extractUserTag(username)
|
||||
Build download (type=series, completedAt)
|
||||
Add allTags, matchedUserTag, tagBadges if showAll;
|
||||
:Push to **userDownloads** if showAll+anyTag or matchedUserTag;
|
||||
endif
|
||||
endif
|
||||
|
||||
if (Title matches Radarr **history** record?) then (yes)
|
||||
:movie = moviesMap.get(match.movieId)\n|| match.movie;
|
||||
if (movie found?) then (yes)
|
||||
:extractAllTags + extractUserTag(username)
|
||||
Build download (type=movie, completedAt)
|
||||
Add allTags, matchedUserTag, tagBadges if showAll;
|
||||
:Push to **userDownloads** if showAll+anyTag or matchedUserTag;
|
||||
endif
|
||||
endif
|
||||
endwhile (no)
|
||||
}
|
||||
|
||||
partition "Process qBittorrent Torrents" {
|
||||
while (More torrents?) is (yes)
|
||||
:Get torrent name;
|
||||
:torrentNameLower = name.toLowerCase();
|
||||
|
||||
if (Matches Sonarr **queue**?) then (yes)
|
||||
:Resolve series → check tag;
|
||||
:mapTorrentToDownload() + enrich;
|
||||
:Push if matches → **continue**;
|
||||
elseif (Matches Radarr **queue**?) then (yes)
|
||||
:Resolve movie → check tag;
|
||||
:mapTorrentToDownload() + enrich;
|
||||
:Push if matches → **continue**;
|
||||
elseif (Matches Sonarr **history**?) then (yes)
|
||||
:Resolve series via seriesMap;
|
||||
:mapTorrentToDownload() + enrich;
|
||||
:Push if matches → **continue**;
|
||||
elseif (Matches Radarr **history**?) then (yes)
|
||||
:Resolve movie via moviesMap;
|
||||
:mapTorrentToDownload() + enrich;
|
||||
:Push if matches → **continue**;
|
||||
else (no match)
|
||||
:Skip torrent (unmatched);
|
||||
endif
|
||||
endwhile (no)
|
||||
}
|
||||
|
||||
:Return JSON response
|
||||
{ user, isAdmin, downloads: userDownloads };
|
||||
|
||||
stop
|
||||
|
||||
legend right
|
||||
**Title Matching Logic**
|
||||
(bidirectional substring, case-insensitive):
|
||||
""rTitle.includes(dlTitle) || dlTitle.includes(rTitle)""
|
||||
|
||||
**Tag Matching Logic** (tagMatchesUser):
|
||||
1. Exact: tag.toLowerCase() === username
|
||||
2. Sanitised: sanitizeTagLabel(tag) === sanitizeTagLabel(username)
|
||||
(handles Ombi-mangled email-style usernames)
|
||||
|
||||
**extractAllTags**: returns all resolved tag labels
|
||||
**extractUserTag**: returns the ONE label matching current user
|
||||
**buildTagBadges**: classifies each tag against full Emby user
|
||||
list → { label, matchedUser: displayName | null }
|
||||
end legend
|
||||
|
||||
@enduml
|
||||
BIN
docs/diagrams/class-data.png
Normal file
|
After Width: | Height: | Size: 304 KiB |
@@ -1,230 +0,0 @@
|
||||
@startuml class-data
|
||||
!theme plain
|
||||
title sofarr — Data Model Diagram
|
||||
|
||||
skinparam classAttributeIconSize 0
|
||||
|
||||
package "External API Responses" {
|
||||
class "SABnzbd Queue Slot" as sabq {
|
||||
+ filename : string
|
||||
+ nzbname : string
|
||||
+ percentage : string
|
||||
+ mb : string
|
||||
+ mbmissing : string
|
||||
+ size : string
|
||||
+ timeleft : string
|
||||
+ status : string
|
||||
+ storage : string
|
||||
}
|
||||
|
||||
class "SABnzbd History Slot" as sabh {
|
||||
+ name : string
|
||||
+ nzb_name : string
|
||||
+ nzbname : string
|
||||
+ status : string
|
||||
+ size : string
|
||||
+ completed_time : string
|
||||
+ storage : string
|
||||
}
|
||||
|
||||
class "Sonarr Queue Record" as sqr {
|
||||
+ id : number
|
||||
+ seriesId : number
|
||||
+ series : SonarrSeries
|
||||
+ title : string
|
||||
+ sourceTitle : string
|
||||
+ trackedDownloadStatus : string
|
||||
+ trackedDownloadState : string
|
||||
+ statusMessages : StatusMessage[]
|
||||
+ errorMessage : string
|
||||
}
|
||||
|
||||
class "Sonarr History Record" as shr {
|
||||
+ id : number
|
||||
+ seriesId : number
|
||||
+ title : string
|
||||
+ sourceTitle : string
|
||||
+ eventType : string
|
||||
}
|
||||
|
||||
class "SonarrSeries" as ss {
|
||||
+ id : number
|
||||
+ title : string
|
||||
+ titleSlug : string
|
||||
+ path : string
|
||||
+ tags : number[]
|
||||
+ images : Image[]
|
||||
+ _instanceUrl : string
|
||||
}
|
||||
|
||||
class "Radarr Queue Record" as rqr {
|
||||
+ id : number
|
||||
+ movieId : number
|
||||
+ movie : RadarrMovie
|
||||
+ title : string
|
||||
+ sourceTitle : string
|
||||
+ trackedDownloadStatus : string
|
||||
+ trackedDownloadState : string
|
||||
+ statusMessages : StatusMessage[]
|
||||
+ errorMessage : string
|
||||
}
|
||||
|
||||
class "Radarr History Record" as rhr {
|
||||
+ id : number
|
||||
+ movieId : number
|
||||
+ title : string
|
||||
+ sourceTitle : string
|
||||
+ eventType : string
|
||||
}
|
||||
|
||||
class "RadarrMovie" as rm {
|
||||
+ id : number
|
||||
+ title : string
|
||||
+ titleSlug : string
|
||||
+ path : string
|
||||
+ tags : number[]
|
||||
+ images : Image[]
|
||||
+ _instanceUrl : string
|
||||
}
|
||||
|
||||
class "Tag" as tag {
|
||||
+ id : number
|
||||
+ label : string
|
||||
}
|
||||
|
||||
class "Image" as img {
|
||||
+ coverType : string
|
||||
+ remoteUrl : string
|
||||
+ url : string
|
||||
}
|
||||
|
||||
class "StatusMessage" as sm {
|
||||
+ title : string
|
||||
+ messages : string[]
|
||||
}
|
||||
|
||||
class "qBittorrent Torrent" as qbt {
|
||||
+ name : string
|
||||
+ hash : string
|
||||
+ size : number
|
||||
+ completed : number
|
||||
+ progress : number (0-1)
|
||||
+ state : string
|
||||
+ dlspeed : number
|
||||
+ eta : number
|
||||
+ num_seeds : number
|
||||
+ num_leechs : number
|
||||
+ availability : number
|
||||
+ category : string
|
||||
+ tags : string
|
||||
+ save_path : string
|
||||
+ content_path : string
|
||||
+ instanceId : string
|
||||
+ instanceName : string
|
||||
}
|
||||
|
||||
class "Emby User" as eu {
|
||||
+ Id : string
|
||||
+ Name : string
|
||||
+ Policy : { IsAdministrator: boolean }
|
||||
}
|
||||
|
||||
sqr *-- ss : embedded\n(includeSeries)
|
||||
rqr *-- rm : embedded\n(includeMovie)
|
||||
sqr *-- sm
|
||||
rqr *-- sm
|
||||
ss *-- img
|
||||
rm *-- img
|
||||
}
|
||||
|
||||
package "sofarr Internal Models" {
|
||||
class "Download Object" as dl {
|
||||
+ type : 'series' | 'movie' | 'torrent'
|
||||
+ title : string
|
||||
+ coverArt : string | null
|
||||
+ status : string
|
||||
+ progress : string
|
||||
+ mb : string
|
||||
+ mbmissing : string
|
||||
+ size : string
|
||||
+ speed : string
|
||||
+ eta : string
|
||||
+ seriesName : string | null
|
||||
+ movieName : string | null
|
||||
+ episodeInfo : object | null
|
||||
+ movieInfo : object | null
|
||||
+ allTags : string[]
|
||||
+ matchedUserTag : string | null
|
||||
+ tagBadges : TagBadge[] | undefined
|
||||
+ importIssues : string[] | null
|
||||
+ downloadPath : string | null
|
||||
+ targetPath : string | null
|
||||
+ arrLink : string | null
|
||||
+ qbittorrent : boolean
|
||||
+ seeds : number
|
||||
+ peers : number
|
||||
+ availability : string
|
||||
+ rawSize : number
|
||||
+ rawSpeed : number
|
||||
+ rawEta : number
|
||||
+ hash : string
|
||||
+ category : string
|
||||
+ completedAt : string
|
||||
}
|
||||
|
||||
class "TagBadge" as tagbadge <<value>> {
|
||||
+ label : string
|
||||
+ matchedUser : string | null
|
||||
}
|
||||
|
||||
class "API Response\n/user-downloads" as apir {
|
||||
+ user : string
|
||||
+ isAdmin : boolean
|
||||
+ downloads : Download[]
|
||||
}
|
||||
|
||||
class "Status Response\n/status" as statr {
|
||||
+ server : ServerInfo
|
||||
+ polling : PollingInfo
|
||||
+ cache : CacheStats
|
||||
+ clients : ClientInfo[]
|
||||
}
|
||||
|
||||
class "ServerInfo" as si {
|
||||
+ uptimeSeconds : number
|
||||
+ nodeVersion : string
|
||||
+ memoryUsageMB : number
|
||||
+ heapUsedMB : number
|
||||
+ heapTotalMB : number
|
||||
}
|
||||
|
||||
class "PollingInfo" as pi {
|
||||
+ enabled : boolean
|
||||
+ intervalMs : number
|
||||
+ lastPoll : PollTimings
|
||||
}
|
||||
|
||||
class "Session Cookie\nemby_user" as cookie {
|
||||
+ id : string
|
||||
+ name : string
|
||||
+ isAdmin : boolean
|
||||
' Note: Emby AccessToken intentionally excluded
|
||||
}
|
||||
|
||||
apir *-- dl
|
||||
statr *-- si
|
||||
statr *-- pi
|
||||
}
|
||||
|
||||
' Data flow connections
|
||||
sabq ..> dl : matched &\ntransformed
|
||||
sabh ..> dl : matched &\ntransformed
|
||||
qbt ..> dl : mapTorrentToDownload()
|
||||
ss ..> dl : coverArt, seriesName,\npath, tags
|
||||
rm ..> dl : coverArt, movieName,\npath, tags
|
||||
tag ..> dl : allTags / matchedUserTag
|
||||
eu ..> cookie : login creates
|
||||
eu ..> tagbadge : buildTagBadges()
|
||||
dl *-- tagbadge : tagBadges[]
|
||||
|
||||
@enduml
|
||||
BIN
docs/diagrams/class-server.png
Normal file
|
After Width: | Height: | Size: 473 KiB |
@@ -1,278 +0,0 @@
|
||||
@startuml class-server
|
||||
!theme plain
|
||||
title sofarr — Server Class / Module Diagram
|
||||
|
||||
package "server/index.js" as entry {
|
||||
class "EntryPoint" as ep <<module>> {
|
||||
- LOG_LEVELS : Object
|
||||
- currentLevel : number
|
||||
- logFile : WriteStream
|
||||
+ shouldLog(level) : boolean
|
||||
--
|
||||
Logging setup, app.listen(),
|
||||
static files, startPoller()
|
||||
}
|
||||
}
|
||||
|
||||
package "server/app.js" as appfactory {
|
||||
class "createApp(options?)" as appfn <<factory>> {
|
||||
+ createApp(skipRateLimits?) : Express
|
||||
--
|
||||
Mounts helmet (CSP nonce),
|
||||
rate limiters, cookie-parser,
|
||||
auth routes (pre-CSRF),
|
||||
verifyCsrf, all other routes,
|
||||
/health, /ready, error handler
|
||||
}
|
||||
}
|
||||
|
||||
package "server/routes" {
|
||||
class "auth.js" as auth <<router>> {
|
||||
+ POST /login (rate-limited)
|
||||
+ GET /me
|
||||
+ GET /csrf
|
||||
+ POST /logout
|
||||
--
|
||||
Authenticates via Emby API
|
||||
Issues emby_user + csrf_token cookies
|
||||
Stores/revokes Emby tokens server-side
|
||||
}
|
||||
|
||||
class "dashboard.js" as dashboard <<router>> {
|
||||
- activeClients : Map<string, ClientInfo>
|
||||
- CLIENT_STALE_MS : 30000
|
||||
--
|
||||
+ GET /stream (SSE, text/event-stream)
|
||||
+ GET /user-downloads
|
||||
+ GET /user-summary
|
||||
+ GET /status
|
||||
+ GET /cover-art
|
||||
--
|
||||
- getCoverArt(item) : string|null
|
||||
- extractAllTags(tags, tagMap) : string[]
|
||||
- extractUserTag(tags, tagMap, username) : string|null
|
||||
- buildTagBadges(allTags, embyUserMap) : TagBadge[]
|
||||
- getEmbyUsers() : Promise<Map>
|
||||
- sanitizeTagLabel(input) : string
|
||||
- tagMatchesUser(tag, username) : boolean
|
||||
- getImportIssues(record) : string[]|null
|
||||
- getSonarrLink(series) : string|null
|
||||
- getRadarrLink(movie) : string|null
|
||||
- getActiveClients() : ClientInfo[]
|
||||
}
|
||||
|
||||
class "emby.js" as emby_r <<router>> {
|
||||
+ GET /sessions
|
||||
+ GET /users/:id
|
||||
+ GET /users
|
||||
+ GET /session/:sessionId/user
|
||||
}
|
||||
|
||||
class "sabnzbd.js" as sab_r <<router>> {
|
||||
+ GET /queue
|
||||
+ GET /history
|
||||
}
|
||||
|
||||
class "sonarr.js" as sonarr_r <<router>> {
|
||||
+ GET /queue
|
||||
+ GET /history
|
||||
+ GET /series/:id
|
||||
+ GET /series
|
||||
}
|
||||
|
||||
class "radarr.js" as radarr_r <<router>> {
|
||||
+ GET /queue
|
||||
+ GET /history
|
||||
+ GET /movies/:id
|
||||
+ GET /movies
|
||||
}
|
||||
}
|
||||
|
||||
package "server/middleware" {
|
||||
class "requireAuth.js" as requireauth <<middleware>> {
|
||||
+ requireAuth(req, res, next) : void
|
||||
--
|
||||
Reads emby_user cookie (signed if COOKIE_SECRET)
|
||||
Validates schema: id, name, isAdmin
|
||||
Attaches user to req.user
|
||||
Returns 401 if absent/tampered/invalid
|
||||
}
|
||||
|
||||
class "verifyCsrf.js" as verifycsrf <<middleware>> {
|
||||
+ verifyCsrf(req, res, next) : void
|
||||
--
|
||||
Exempt: GET, HEAD, OPTIONS
|
||||
Compares csrf_token cookie
|
||||
vs X-CSRF-Token header
|
||||
using crypto.timingSafeEqual
|
||||
Returns 403 on mismatch/missing
|
||||
}
|
||||
}
|
||||
|
||||
package "server/utils" {
|
||||
class "MemoryCache" as cache {
|
||||
- store : Map<string, CacheEntry>
|
||||
+ get(key) : any|null
|
||||
+ set(key, value, ttlMs) : void
|
||||
+ invalidate(key) : void
|
||||
+ clear() : void
|
||||
+ getStats() : CacheStats
|
||||
}
|
||||
|
||||
class "CacheEntry" as ce <<value>> {
|
||||
+ value : any
|
||||
+ expiresAt : number
|
||||
}
|
||||
|
||||
class "CacheStats" as cs <<value>> {
|
||||
+ entryCount : number
|
||||
+ totalSizeBytes : number
|
||||
+ entries : CacheEntryStats[]
|
||||
}
|
||||
|
||||
class "Poller" as poller <<module>> {
|
||||
- POLL_INTERVAL : number
|
||||
- POLLING_ENABLED : boolean
|
||||
- polling : boolean
|
||||
- lastPollTimings : PollTimings|null
|
||||
- intervalHandle : number|null
|
||||
--
|
||||
+ startPoller() : void
|
||||
+ stopPoller() : void
|
||||
+ pollAllServices() : Promise<void>
|
||||
+ getLastPollTimings() : PollTimings|null
|
||||
--
|
||||
- timed(label, fn) : TimedResult
|
||||
}
|
||||
|
||||
class "PollTimings" as pt <<value>> {
|
||||
+ totalMs : number
|
||||
+ timestamp : string (ISO)
|
||||
+ tasks : { label, ms }[]
|
||||
}
|
||||
|
||||
class "Config" as config <<module>> {
|
||||
+ getSABnzbdInstances() : Instance[]
|
||||
+ getSonarrInstances() : Instance[]
|
||||
+ getRadarrInstances() : Instance[]
|
||||
+ getQbittorrentInstances() : Instance[]
|
||||
--
|
||||
- parseInstances(envVar, ...) : Instance[]
|
||||
}
|
||||
|
||||
class "Instance" as inst <<value>> {
|
||||
+ id : string
|
||||
+ name : string
|
||||
+ url : string
|
||||
+ apiKey : string
|
||||
+ username? : string
|
||||
+ password? : string
|
||||
}
|
||||
|
||||
class "QBittorrentClient" as qbt {
|
||||
- id : string
|
||||
- name : string
|
||||
- url : string
|
||||
- username : string
|
||||
- password : string
|
||||
- authCookie : string|null
|
||||
--
|
||||
+ login() : Promise<boolean>
|
||||
+ makeRequest(endpoint, config) : Promise<Response>
|
||||
+ getTorrents() : Promise<Torrent[]>
|
||||
}
|
||||
|
||||
class "qbittorrent.js" as qbt_mod <<module>> {
|
||||
- persistedClients : QBittorrentClient[]|null
|
||||
--
|
||||
+ getTorrents() : Promise<Torrent[]>
|
||||
+ getClients() : QBittorrentClient[]
|
||||
+ mapTorrentToDownload(torrent) : Download
|
||||
+ formatBytes(bytes) : string
|
||||
+ formatSpeed(bps) : string
|
||||
+ formatEta(seconds) : string
|
||||
}
|
||||
|
||||
class "Logger" as logger <<module>> {
|
||||
- logFile : WriteStream
|
||||
+ logToFile(message) : void
|
||||
}
|
||||
|
||||
class "TokenStore" as tokenstore <<module>> {
|
||||
- store : Object (in-memory)
|
||||
- STORE_PATH : string (DATA_DIR/tokens.json)
|
||||
- TOKEN_TTL_MS : 31 days
|
||||
--
|
||||
+ storeToken(userId, accessToken) : void
|
||||
+ getToken(userId) : {accessToken}|null
|
||||
+ clearToken(userId) : void
|
||||
--
|
||||
Atomic write (.tmp → rename)
|
||||
Pruned on startup + hourly
|
||||
}
|
||||
|
||||
class "SanitizeError" as sanitize <<module>> {
|
||||
+ sanitizeError(err) : string
|
||||
--
|
||||
Redacts: query-param secrets,
|
||||
auth headers, bearer tokens,
|
||||
basic-auth URLs
|
||||
}
|
||||
|
||||
class "TagBadge" as tb <<value>> {
|
||||
+ label : string
|
||||
+ matchedUser : string | null
|
||||
}
|
||||
|
||||
class "ClientInfo" as ci <<value>> {
|
||||
+ user : string
|
||||
+ type : 'sse'
|
||||
+ connectedAt : number (timestamp)
|
||||
+ lastSeen : number (timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
' Relationships
|
||||
ep --> appfn : createApp()
|
||||
ep --> poller : startPoller()
|
||||
|
||||
appfn --> auth : /api/auth (pre-CSRF)
|
||||
appfn --> verifycsrf : /api (all routes below)
|
||||
appfn --> dashboard
|
||||
appfn --> emby_r
|
||||
appfn --> sab_r
|
||||
appfn --> sonarr_r
|
||||
appfn --> radarr_r
|
||||
|
||||
dashboard --> requireauth : uses
|
||||
emby_r --> requireauth : uses
|
||||
sab_r --> requireauth : uses
|
||||
sonarr_r --> requireauth : uses
|
||||
radarr_r --> requireauth : uses
|
||||
|
||||
auth --> tokenstore : storeToken / getToken / clearToken
|
||||
|
||||
dashboard --> cache : read/write
|
||||
dashboard --> poller : pollAllServices()
|
||||
dashboard --> qbt_mod : mapTorrentToDownload()
|
||||
dashboard --> config
|
||||
|
||||
poller --> cache : set poll:* keys
|
||||
poller --> config : get instances
|
||||
poller --> qbt_mod : getTorrents()
|
||||
|
||||
qbt_mod --> config : getQbittorrentInstances()
|
||||
qbt_mod *-- qbt : creates
|
||||
qbt --> logger
|
||||
|
||||
cache *-- ce : stores
|
||||
cache ..> cs : returns from getStats()
|
||||
poller ..> pt : stores/returns
|
||||
dashboard *-- ci : stores in activeClients
|
||||
|
||||
config ..> inst : returns
|
||||
|
||||
auth ..> sanitize : sanitizeError on catch
|
||||
dashboard ..> sanitize : sanitizeError on catch
|
||||
|
||||
@enduml
|
||||
BIN
docs/diagrams/component.png
Normal file
|
After Width: | Height: | Size: 297 KiB |
@@ -1,118 +0,0 @@
|
||||
@startuml component
|
||||
!theme plain
|
||||
title sofarr — Component Diagram
|
||||
|
||||
skinparam componentStyle rectangle
|
||||
skinparam packageStyle frame
|
||||
|
||||
package "Browser" as browser {
|
||||
[index.html] as html
|
||||
[app.js] as appjs
|
||||
[style.css] as css
|
||||
html ..> appjs : loads
|
||||
html ..> css : loads
|
||||
}
|
||||
|
||||
package "Express Server" as server {
|
||||
|
||||
[index.js\nEntry Point] as entry
|
||||
[app.js\ncreatApp() factory] as appfactory
|
||||
|
||||
package "Middleware" {
|
||||
[helmet\n(CSP nonce, HSTS)] as hm
|
||||
[express-rate-limit\n(API + login)] as rl
|
||||
[cookie-parser\n(signed cookies)] as cp
|
||||
[express.json\n(64kb limit)] as ej
|
||||
[express.static] as es
|
||||
[requireAuth.js] as requireauth
|
||||
[verifyCsrf.js\n(double-submit)] as verifycsrf
|
||||
}
|
||||
|
||||
package "Routes" as routes {
|
||||
[auth.js\n/api/auth\n(pre-CSRF)] as auth
|
||||
[dashboard.js\n/api/dashboard\n(+SSE /stream)] as dashboard
|
||||
[emby.js\n/api/emby] as emby_route
|
||||
[sabnzbd.js\n/api/sabnzbd] as sab_route
|
||||
[sonarr.js\n/api/sonarr] as sonarr_route
|
||||
[radarr.js\n/api/radarr] as radarr_route
|
||||
}
|
||||
|
||||
package "Utilities" as utils {
|
||||
[poller.js] as poller
|
||||
[cache.js\nMemoryCache] as cache
|
||||
[config.js] as config
|
||||
[qbittorrent.js\nQBittorrentClient] as qbt
|
||||
[tokenStore.js\n(tokens.json)] as tokenstore
|
||||
[sanitizeError.js] as sanitize
|
||||
[logger.js] as logger
|
||||
}
|
||||
|
||||
entry --> appfactory : createApp()
|
||||
entry --> es : serve public/
|
||||
entry --> poller : startPoller()
|
||||
|
||||
appfactory --> hm
|
||||
appfactory --> rl
|
||||
appfactory --> cp
|
||||
appfactory --> ej
|
||||
appfactory --> auth : mount before verifyCsrf
|
||||
appfactory --> verifycsrf : applied to all /api below
|
||||
appfactory --> dashboard
|
||||
appfactory --> emby_route
|
||||
appfactory --> sab_route
|
||||
appfactory --> sonarr_route
|
||||
appfactory --> radarr_route
|
||||
|
||||
emby_route --> requireauth
|
||||
sab_route --> requireauth
|
||||
sonarr_route --> requireauth
|
||||
radarr_route --> requireauth
|
||||
dashboard --> requireauth
|
||||
|
||||
auth --> tokenstore : storeToken / getToken / clearToken
|
||||
|
||||
dashboard --> cache : read poll:* keys
|
||||
dashboard --> poller : pollAllServices()\n(on-demand mode)
|
||||
dashboard --> config : getSonarrInstances()\ngetRadarrInstances()
|
||||
dashboard --> qbt : mapTorrentToDownload()
|
||||
|
||||
poller --> cache : set poll:* keys
|
||||
poller --> config : get all instances
|
||||
poller --> qbt : getTorrents()
|
||||
poller --> logger
|
||||
|
||||
qbt --> config : getQbittorrentInstances()
|
||||
qbt --> logger
|
||||
|
||||
auth ..> sanitize
|
||||
dashboard ..> sanitize
|
||||
|
||||
note "Browser uses EventSource\n(SSE) to /stream.\nServer pushes on each\npoll cycle — no client timer." as sseNote
|
||||
sseNote .. dashboard
|
||||
}
|
||||
|
||||
cloud "External Services" as external {
|
||||
[Emby / Jellyfin] as emby
|
||||
[SABnzbd] as sab
|
||||
[Sonarr] as sonarr
|
||||
[Radarr] as radarr
|
||||
[qBittorrent] as qbit
|
||||
}
|
||||
|
||||
auth --> emby : authenticate\nuser profile
|
||||
dashboard --> emby : GET /Users\n(user-summary + tag badge classification)
|
||||
emby_route --> emby
|
||||
sab_route --> sab
|
||||
sonarr_route --> sonarr
|
||||
radarr_route --> radarr
|
||||
|
||||
poller --> sab : queue + history
|
||||
poller --> sonarr : tags + queue + history
|
||||
poller --> radarr : tags + queue + history
|
||||
qbt --> qbit : login + torrents/info
|
||||
|
||||
appjs --> auth : POST /login\nGET /me
|
||||
appjs --> dashboard : GET /user-downloads\nGET /status
|
||||
es --> html : serve static
|
||||
|
||||
@enduml
|
||||
BIN
docs/diagrams/seq-auth.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
@@ -1,104 +0,0 @@
|
||||
@startuml seq-auth
|
||||
!theme plain
|
||||
title sofarr — Authentication Sequence
|
||||
|
||||
actor User as user
|
||||
participant "Browser\n(app.js)" as browser
|
||||
participant "Express\n/api/auth" as auth
|
||||
participant "TokenStore\n(tokens.json)" as tokens
|
||||
participant "Emby\nServer" as emby
|
||||
|
||||
== Page Load ==
|
||||
user -> browser : Navigate to sofarr
|
||||
activate browser
|
||||
browser -> auth : GET /api/auth/me
|
||||
activate auth
|
||||
auth -> auth : Read emby_user cookie\n(signed if COOKIE_SECRET set)
|
||||
alt Cookie exists and valid
|
||||
auth --> browser : { authenticated: true, user: { name, isAdmin } }
|
||||
browser -> auth : GET /api/auth/csrf
|
||||
activate auth
|
||||
auth -> auth : Generate 32-byte hex csrfToken
|
||||
auth --> browser : { csrfToken } + Set csrf_token cookie
|
||||
deactivate auth
|
||||
browser -> browser : store csrfToken in memory
|
||||
browser -> browser : showDashboard()
|
||||
browser -> browser : startAutoRefresh()
|
||||
browser -> browser : dismissSplash()
|
||||
else No cookie / tampered
|
||||
auth --> browser : { authenticated: false }
|
||||
browser -> browser : dismissSplash()
|
||||
browser -> browser : showLogin()
|
||||
end
|
||||
deactivate auth
|
||||
|
||||
== Login ==
|
||||
user -> browser : Enter username + password\n(+ optional rememberMe checkbox)
|
||||
browser -> auth : POST /api/auth/login\n{ username, password, rememberMe }
|
||||
activate auth
|
||||
note right of auth
|
||||
Rate limiter: max 10 failed
|
||||
attempts per IP / 15 min
|
||||
(successful requests excluded)
|
||||
end note
|
||||
auth -> emby : POST /Users/authenticatebyname\n{ Username, Pw }\nX-Emby-Authorization: MediaBrowser\nDeviceId = sha256(username)[0:16]
|
||||
activate emby
|
||||
alt Valid credentials
|
||||
emby --> auth : { User: { Id }, AccessToken }
|
||||
auth -> emby : GET /Users/{userId}\nX-MediaBrowser-Token: AccessToken
|
||||
emby --> auth : { Name, Policy: { IsAdministrator } }
|
||||
deactivate emby
|
||||
auth -> tokens : storeToken(userId, AccessToken)
|
||||
note right of tokens
|
||||
Stored server-side only.
|
||||
Never sent to the client.
|
||||
31-day TTL, atomic JSON write.
|
||||
end note
|
||||
auth -> auth : Set emby_user cookie\n{ id, name, isAdmin }\nhttpOnly, sameSite=strict\nsecure (prod), signed (COOKIE_SECRET)\nrememberMe=true → Max-Age 30d\nrememberMe=false → session cookie
|
||||
auth -> auth : Generate csrfToken\n(32-byte random hex)
|
||||
auth -> auth : Set csrf_token cookie\nhttpOnly=false (JS-readable)\nsameSite=strict, secure (prod)
|
||||
auth --> browser : { success: true, user, csrfToken }
|
||||
browser -> browser : store csrfToken in memory
|
||||
browser -> browser : fadeOutLogin()
|
||||
browser -> browser : showDashboard()
|
||||
browser -> browser : startAutoRefresh()
|
||||
browser -> browser : dismissSplash()
|
||||
else Invalid credentials
|
||||
emby --> auth : 401 Error
|
||||
deactivate emby
|
||||
auth --> browser : { success: false, error: "Invalid username or password" }
|
||||
browser -> browser : showLoginError()
|
||||
end
|
||||
deactivate auth
|
||||
|
||||
== CSRF Token Refresh (after page reload) ==
|
||||
note over browser : csrfToken lost from memory\non hard page reload
|
||||
browser -> auth : GET /api/auth/csrf
|
||||
activate auth
|
||||
auth -> auth : Generate new csrfToken
|
||||
auth --> browser : { csrfToken } + new csrf_token cookie
|
||||
deactivate auth
|
||||
browser -> browser : store new csrfToken in memory
|
||||
|
||||
== Logout ==
|
||||
user -> browser : Click Logout
|
||||
browser -> browser : stopAutoRefresh()
|
||||
browser -> auth : POST /api/auth/logout\n(no CSRF required — auth routes\nexempt; sameSite:strict protects)
|
||||
activate auth
|
||||
auth -> auth : Parse emby_user cookie → user
|
||||
auth -> tokens : getToken(user.id)
|
||||
activate tokens
|
||||
tokens --> auth : { accessToken }
|
||||
deactivate tokens
|
||||
auth -> emby : POST /Sessions/Logout\nX-MediaBrowser-Token: accessToken
|
||||
activate emby
|
||||
emby --> auth : 204 / error (ignored)
|
||||
deactivate emby
|
||||
auth -> tokens : clearToken(user.id)
|
||||
auth -> auth : clearCookie(emby_user)\nclearCookie(csrf_token)
|
||||
auth --> browser : { success: true }
|
||||
deactivate auth
|
||||
browser -> browser : showLogin()
|
||||
|
||||
deactivate browser
|
||||
@enduml
|
||||
BIN
docs/diagrams/seq-dashboard.png
Normal file
|
After Width: | Height: | Size: 161 KiB |
@@ -1,67 +0,0 @@
|
||||
@startuml seq-dashboard
|
||||
!theme plain
|
||||
title sofarr — Dashboard SSE Stream Sequence
|
||||
|
||||
actor User as user
|
||||
participant "Browser\n(app.js)" as browser
|
||||
participant "Express\n/api/dashboard" as dashboard
|
||||
participant "MemoryCache" as cache
|
||||
participant "Poller" as poller
|
||||
participant "External\nServices" as ext
|
||||
|
||||
== SSE Connection (on login / page load) ==
|
||||
user -> browser : Login success\nor valid session
|
||||
activate browser
|
||||
browser -> dashboard : GET /api/dashboard/stream\n(EventSource, Cookie: emby_user)
|
||||
activate dashboard
|
||||
|
||||
dashboard -> dashboard : requireAuth: parse cookie\nextract username, isAdmin
|
||||
dashboard -> dashboard : Set headers:\nContent-Type: text/event-stream\nX-Accel-Buffering: no
|
||||
dashboard -> dashboard : Register in activeClients Map\n{ user, type:'sse', connectedAt }
|
||||
|
||||
alt Polling disabled AND cache empty
|
||||
dashboard -> poller : pollAllServices()
|
||||
activate poller
|
||||
poller -> ext : Parallel API calls
|
||||
ext --> poller : Raw data
|
||||
poller -> cache : set poll:* keys (TTL=30s)
|
||||
deactivate poller
|
||||
end
|
||||
|
||||
== Initial Payload (sent immediately on connect) ==
|
||||
dashboard -> cache : get all poll:* keys
|
||||
dashboard -> dashboard : Build seriesMap, moviesMap,\nsonarrTagMap, radarrTagMap
|
||||
alt showAll=true
|
||||
dashboard -> cache : get('emby:users')
|
||||
alt cache miss
|
||||
dashboard -> ext : GET /Users (Emby)
|
||||
ext --> dashboard : [{ Name, ... }]
|
||||
dashboard -> cache : set('emby:users', map, 60s)
|
||||
end
|
||||
end
|
||||
dashboard -> dashboard : Match SABnzbd/qBit downloads\nvs Sonarr/Radarr records\nextractUserTag / buildTagBadges
|
||||
dashboard --> browser : data: { user, isAdmin, downloads }
|
||||
browser -> browser : hideLoading()\nrenderDownloads()
|
||||
|
||||
== Pushed Updates (on every poll cycle) ==
|
||||
loop Each poll cycle completes
|
||||
poller -> poller : pollAllServices() complete
|
||||
poller -> dashboard : onPollComplete callback fires
|
||||
dashboard -> cache : get all poll:* keys
|
||||
dashboard -> dashboard : Rebuild download payload
|
||||
dashboard --> browser : data: { user, isAdmin, downloads }
|
||||
browser -> browser : renderDownloads() (diff-based)
|
||||
end
|
||||
|
||||
== Heartbeat (every 25s) ==
|
||||
dashboard --> browser : : heartbeat
|
||||
note right : Keeps connection alive\nthrough idle-timeout proxies
|
||||
|
||||
== Client Disconnects ==
|
||||
user -> browser : Close tab / logout
|
||||
browser -> dashboard : TCP close (req 'close' event)
|
||||
dashboard -> dashboard : offPollComplete(callback)\nclearInterval(heartbeat)\ndelete activeClients[key]
|
||||
deactivate dashboard
|
||||
deactivate browser
|
||||
|
||||
@enduml
|
||||
BIN
docs/diagrams/seq-polling.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
@@ -1,93 +0,0 @@
|
||||
@startuml seq-polling
|
||||
!theme plain
|
||||
title sofarr — Background Polling Cycle
|
||||
|
||||
participant "index.js\n(startup)" as entry
|
||||
participant "Poller" as poller
|
||||
participant "Config" as config
|
||||
participant "SABnzbd\n(per instance)" as sab
|
||||
participant "Sonarr\n(per instance)" as sonarr
|
||||
participant "Radarr\n(per instance)" as radarr
|
||||
participant "qBittorrent\nClient" as qbt
|
||||
participant "MemoryCache" as cache
|
||||
|
||||
== Startup ==
|
||||
entry -> poller : startPoller()
|
||||
activate poller
|
||||
|
||||
alt POLL_INTERVAL > 0
|
||||
poller -> poller : pollAllServices() (immediate)
|
||||
poller -> poller : setInterval(pollAllServices,\nPOLL_INTERVAL)
|
||||
else POLL_INTERVAL = 0
|
||||
poller --> entry : "Polling disabled, on-demand mode"
|
||||
end
|
||||
|
||||
== Poll Cycle ==
|
||||
poller -> poller : Check: polling flag?\n(skip if concurrent)
|
||||
poller -> poller : polling = true
|
||||
poller -> poller : start = Date.now()
|
||||
|
||||
poller -> config : getSABnzbdInstances()
|
||||
config --> poller : [{ id, url, apiKey }]
|
||||
poller -> config : getSonarrInstances()
|
||||
config --> poller : [{ id, url, apiKey }]
|
||||
poller -> config : getRadarrInstances()
|
||||
config --> poller : [{ id, url, apiKey }]
|
||||
|
||||
note over poller : All fetches run in\nparallel via Promise.all,\neach wrapped in timed()
|
||||
|
||||
par SABnzbd Queue
|
||||
poller -> sab : GET /api?mode=queue
|
||||
sab --> poller : { queue: { slots, status, speed } }
|
||||
and SABnzbd History
|
||||
poller -> sab : GET /api?mode=history&limit=10
|
||||
sab --> poller : { history: { slots } }
|
||||
and Sonarr Tags
|
||||
poller -> sonarr : GET /api/v3/tag
|
||||
sonarr --> poller : [{ id, label }]
|
||||
and Sonarr Queue
|
||||
poller -> sonarr : GET /api/v3/queue\n?includeSeries=true
|
||||
sonarr --> poller : { records: [{ seriesId, series, ... }] }
|
||||
and Sonarr History
|
||||
poller -> sonarr : GET /api/v3/history\n?pageSize=10
|
||||
sonarr --> poller : { records: [{ seriesId, ... }] }
|
||||
and Radarr Queue
|
||||
poller -> radarr : GET /api/v3/queue\n?includeMovie=true
|
||||
radarr --> poller : { records: [{ movieId, movie, ... }] }
|
||||
and Radarr History
|
||||
poller -> radarr : GET /api/v3/history\n?pageSize=10
|
||||
radarr --> poller : { records: [{ movieId, ... }] }
|
||||
and Radarr Tags
|
||||
poller -> radarr : GET /api/v3/tag
|
||||
radarr --> poller : [{ id, label }]
|
||||
and qBittorrent
|
||||
poller -> qbt : getTorrents()
|
||||
qbt --> poller : [{ name, progress, ... }]
|
||||
end
|
||||
|
||||
poller -> poller : Record per-task timings\nlastPollTimings = { totalMs,\ntimestamp, tasks: [{label, ms}] }
|
||||
|
||||
poller -> poller : cacheTTL = POLL_INTERVAL × 3
|
||||
|
||||
poller -> cache : set('poll:sab-queue', ..., cacheTTL)
|
||||
poller -> cache : set('poll:sab-history', ..., cacheTTL)
|
||||
poller -> cache : set('poll:sonarr-tags', ..., cacheTTL)
|
||||
|
||||
note over poller : Tag queue records with\n_instanceUrl on embedded\nseries/movie objects
|
||||
|
||||
poller -> cache : set('poll:sonarr-queue', ..., cacheTTL)
|
||||
poller -> cache : set('poll:sonarr-history', ..., cacheTTL)
|
||||
poller -> cache : set('poll:radarr-queue', ..., cacheTTL)
|
||||
poller -> cache : set('poll:radarr-history', ..., cacheTTL)
|
||||
poller -> cache : set('poll:radarr-tags', ..., cacheTTL)
|
||||
poller -> cache : set('poll:qbittorrent', ..., cacheTTL)
|
||||
|
||||
poller -> poller : Notify SSE subscribers\npollSubscribers.forEach(cb => cb())
|
||||
|
||||
note over poller : Each registered SSE client\ncallback rebuilds its payload\nand writes a data: frame
|
||||
|
||||
poller -> poller : polling = false\nlog elapsed time
|
||||
|
||||
deactivate poller
|
||||
|
||||
@enduml
|
||||
BIN
docs/diagrams/state-poller.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
@@ -1,67 +0,0 @@
|
||||
@startuml state-poller
|
||||
!theme plain
|
||||
title sofarr — Poller State Diagram
|
||||
|
||||
[*] --> CheckConfig : startPoller()
|
||||
|
||||
state CheckConfig <<choice>>
|
||||
CheckConfig --> Disabled : POLL_INTERVAL = 0\nor 'off' / 'false'
|
||||
CheckConfig --> Idle : POLL_INTERVAL > 0
|
||||
|
||||
state Disabled {
|
||||
state "On-demand mode\nNo background timer" as od
|
||||
od : Data fetched only when\na dashboard request\nfinds empty cache
|
||||
}
|
||||
|
||||
Disabled --> Polling : pollAllServices()\n(triggered by dashboard request)
|
||||
Polling --> Disabled : Poll complete\n(return to on-demand)
|
||||
|
||||
state Idle {
|
||||
state "Waiting for\nnext interval" as waiting
|
||||
}
|
||||
|
||||
Idle --> Polling : setInterval fires\nor immediate first poll
|
||||
|
||||
state Polling {
|
||||
state "polling = true" as lock
|
||||
state "Fetching all services\n(Promise.all)" as fetching
|
||||
state "Storing results\nin cache" as storing
|
||||
state "Recording timings" as timing
|
||||
|
||||
[*] --> lock
|
||||
lock --> fetching
|
||||
fetching --> storing : All promises resolved
|
||||
fetching --> ErrorState : Any individual service\nerror (caught per-service)
|
||||
storing --> notifying : Cache updated
|
||||
state "Notifying SSE\nsubscribers" as notifying
|
||||
notifying --> timing
|
||||
timing --> [*] : polling = false
|
||||
}
|
||||
|
||||
state ErrorState as "Handle Error" {
|
||||
state "Log error\npolling = false" as err
|
||||
}
|
||||
|
||||
ErrorState --> Idle : Next interval
|
||||
Polling --> Idle : Poll complete\n(back to waiting)
|
||||
|
||||
state "Concurrent Poll\nAttempt" as skip {
|
||||
state "polling === true\n→ skip" as sk
|
||||
}
|
||||
|
||||
Idle --> skip : Interval fires while\nprevious still running
|
||||
skip --> Idle : Log "still running,\nskipping"
|
||||
|
||||
note right of Polling
|
||||
**Cache TTL**: POLL_INTERVAL × 3
|
||||
Ensures data survives between polls
|
||||
even if one cycle is slow.
|
||||
end note
|
||||
|
||||
note right of Disabled
|
||||
**Cache TTL**: 30000ms (30s)
|
||||
After expiry, next dashboard
|
||||
request triggers a fresh poll.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
BIN
docs/diagrams/state-ui.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
@@ -1,73 +0,0 @@
|
||||
@startuml state-ui
|
||||
!theme plain
|
||||
title sofarr — Frontend UI State Diagram
|
||||
|
||||
[*] --> SplashScreen : Page load
|
||||
|
||||
state SplashScreen {
|
||||
state "Showing splash\n(min 1.2s)" as showing
|
||||
}
|
||||
|
||||
SplashScreen --> CheckAuth : checkAuthentication()
|
||||
|
||||
state CheckAuth <<choice>>
|
||||
CheckAuth --> LoginForm : No session cookie
|
||||
CheckAuth --> Dashboard : Valid session
|
||||
|
||||
state LoginForm {
|
||||
state "Idle" as lf_idle
|
||||
state "Submitting" as lf_submit
|
||||
state "Error" as lf_error
|
||||
|
||||
lf_idle --> lf_submit : Submit form
|
||||
lf_submit --> lf_error : Auth failed
|
||||
lf_error --> lf_submit : Re-submit
|
||||
lf_submit --> FadeOutLogin : Auth success
|
||||
}
|
||||
|
||||
state FadeOutLogin {
|
||||
state "CSS transition\n(opacity → 0)" as fade
|
||||
}
|
||||
|
||||
FadeOutLogin --> SplashScreen2 : Show splash\nwhile loading
|
||||
|
||||
state SplashScreen2 as "Splash (loading data)" {
|
||||
state "startSSE() — awaiting\nfirst SSE message" as fetching
|
||||
}
|
||||
|
||||
SplashScreen2 --> Dashboard : Data loaded\ndismissSplash()
|
||||
|
||||
state Dashboard {
|
||||
state "Rendering Cards" as rendering
|
||||
state "Status Panel Open" as status_open
|
||||
state "Status Panel Closed" as status_closed
|
||||
|
||||
[*] --> rendering
|
||||
rendering --> rendering : SSE message received
|
||||
→ renderDownloads()
|
||||
rendering --> rendering : Theme change
|
||||
|
||||
status_closed --> status_open : Click "Status" btn
|
||||
(admin only)
|
||||
status_open --> status_closed : Click close (×)
|
||||
status_open --> status_open : 5s timer
|
||||
→ renderStatusPanel()
|
||||
|
||||
[*] --> status_closed
|
||||
|
||||
state "SSE Connection" as sse {
|
||||
state "Connecting" as sc
|
||||
state "Connected" as scon
|
||||
state "Reconnecting" as srec
|
||||
sc --> scon : First message received
|
||||
scon --> srec : Connection lost
|
||||
srec --> scon : Browser auto-reconnects
|
||||
scon --> sc : showAll toggle changed
|
||||
}
|
||||
}
|
||||
|
||||
Dashboard --> LoginForm : Logout
|
||||
(stopSSE,
|
||||
clear state)
|
||||
|
||||
@enduml
|
||||