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

@@ -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

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
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

View File

@@ -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