docs: migrate all diagrams from PlantUML to Mermaid
All checks were successful
Build and Push Docker Image / build (push) Successful in 38s
CI / Security audit (push) Successful in 52s
CI / Tests & coverage (push) Successful in 1m0s
CI / Security audit (pull_request) Successful in 48s
CI / Tests & coverage (pull_request) Successful in 57s
All checks were successful
Build and Push Docker Image / build (push) Successful in 38s
CI / Security audit (push) Successful in 52s
CI / Tests & coverage (push) Successful in 1m0s
CI / Security audit (pull_request) Successful in 48s
CI / Tests & coverage (pull_request) Successful in 57s
- Replace section 13 of ARCHITECTURE.md with 9 inline Mermaid diagrams (component, auth sequence, dashboard SSE sequence, polling sequence, server class, data model, UI state, poller state, matching flowchart) - Diagrams render natively in Gitea/GitHub — no CI job required - Delete docs/diagrams/*.puml (all 9 files) - Delete .gitea/workflows/render-diagrams.yml - Update CI/CD table note and ToC entry
This commit is contained in:
@@ -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)
|
10. [Frontend Architecture](#10-frontend-architecture)
|
||||||
11. [Configuration](#11-configuration)
|
11. [Configuration](#11-configuration)
|
||||||
12. [Deployment](#12-deployment)
|
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` |
|
| `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 |
|
| `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
|
### 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)
|
subgraph Middleware
|
||||||
- **Dashboard Request**: [`diagrams/seq-dashboard.puml`](diagrams/seq-dashboard.puml)
|
hm[helmet\nCSP nonce + HSTS]
|
||||||
- **Polling Cycle**: [`diagrams/seq-polling.puml`](diagrams/seq-polling.puml)
|
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)
|
subgraph Utilities
|
||||||
- **Data Model**: [`diagrams/class-data.puml`](diagrams/class-data.puml)
|
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)
|
appfactory --> hm & rl & cp & ej
|
||||||
- **Poller States**: [`diagrams/state-poller.puml`](diagrams/state-poller.puml)
|
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
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,157 +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
|
|
||||||
@@ -1,239 +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 "SSE Event\n/stream (data: frame)" as sser {
|
|
||||||
+ user : string
|
|
||||||
+ isAdmin : boolean
|
|
||||||
+ downloads : Download[]
|
|
||||||
' Same shape as /user-downloads response.
|
|
||||||
' Pushed after every poll cycle completes.
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
sser *-- 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
|
|
||||||
@@ -1,281 +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
|
|
||||||
- subscribers : Set<Function>
|
|
||||||
--
|
|
||||||
+ startPoller() : void
|
|
||||||
+ stopPoller() : void
|
|
||||||
+ pollAllServices() : Promise<void>
|
|
||||||
+ getLastPollTimings() : PollTimings|null
|
|
||||||
+ onPollComplete(cb) : void
|
|
||||||
+ offPollComplete(cb) : void
|
|
||||||
--
|
|
||||||
- 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
|
|
||||||
@@ -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\ncreateApp() 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\nGET /csrf\nPOST /logout
|
|
||||||
appjs --> dashboard : GET /stream (SSE)\nGET /user-downloads\nGET /status
|
|
||||||
es --> html : serve static
|
|
||||||
|
|
||||||
@enduml
|
|
||||||
@@ -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 (if TRUST_PROXY), 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 (if TRUST_PROXY)
|
|
||||||
auth --> browser : { success: true, user, csrfToken }
|
|
||||||
browser -> browser : store csrfToken in memory
|
|
||||||
browser -> browser : fadeOutLogin()
|
|
||||||
browser -> browser : showDashboard()
|
|
||||||
browser -> browser : startSSE()
|
|
||||||
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 : stopSSE()
|
|
||||||
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
|
|
||||||
@@ -1,68 +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
|
|
||||||
@@ -1,89 +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, cache
|
|
||||||
All 9 fetches run in parallel via Promise.all,
|
|
||||||
each wrapped in timed(). Shown sequentially below.
|
|
||||||
end note
|
|
||||||
|
|
||||||
group Parallel API Fetches (Promise.all)
|
|
||||||
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
|
|
||||||
sonarr --> poller : [{ id, label }]
|
|
||||||
poller -> sonarr : GET /api/v3/queue?includeSeries=true
|
|
||||||
sonarr --> poller : { records: [{ seriesId, series, ... }] }
|
|
||||||
poller -> sonarr : GET /api/v3/history?pageSize=10
|
|
||||||
sonarr --> poller : { records: [{ seriesId, ... }] }
|
|
||||||
poller -> radarr : GET /api/v3/queue?includeMovie=true
|
|
||||||
radarr --> poller : { records: [{ movieId, movie, ... }] }
|
|
||||||
poller -> radarr : GET /api/v3/history?pageSize=10
|
|
||||||
radarr --> poller : { records: [{ movieId, ... }] }
|
|
||||||
poller -> radarr : GET /api/v3/tag
|
|
||||||
radarr --> poller : [{ id, label }]
|
|
||||||
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
|
|
||||||
@@ -1,68 +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
|
|
||||||
@@ -1,68 +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\n-> renderDownloads()
|
|
||||||
rendering --> rendering : Theme change
|
|
||||||
|
|
||||||
status_closed --> status_open : Click "Status" btn\n(admin only)
|
|
||||||
status_open --> status_closed : Click close (x)
|
|
||||||
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\n(stopSSE, clear state)
|
|
||||||
|
|
||||||
@enduml
|
|
||||||
Reference in New Issue
Block a user