diff --git a/.gitea/workflows/render-diagrams.yml b/.gitea/workflows/render-diagrams.yml deleted file mode 100644 index 0639cb1..0000000 --- a/.gitea/workflows/render-diagrams.yml +++ /dev/null @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c1a80e9..70699ee 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 <> + 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 <> + 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 +``` diff --git a/docs/diagrams/activity-matching.png b/docs/diagrams/activity-matching.png new file mode 100644 index 0000000..d3d5ef7 Binary files /dev/null and b/docs/diagrams/activity-matching.png differ diff --git a/docs/diagrams/activity-matching.puml b/docs/diagrams/activity-matching.puml deleted file mode 100644 index b9d7718..0000000 --- a/docs/diagrams/activity-matching.puml +++ /dev/null @@ -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 diff --git a/docs/diagrams/class-data.png b/docs/diagrams/class-data.png new file mode 100644 index 0000000..84b04da Binary files /dev/null and b/docs/diagrams/class-data.png differ diff --git a/docs/diagrams/class-data.puml b/docs/diagrams/class-data.puml deleted file mode 100644 index e3631d1..0000000 --- a/docs/diagrams/class-data.puml +++ /dev/null @@ -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 <> { - + 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 diff --git a/docs/diagrams/class-server.png b/docs/diagrams/class-server.png new file mode 100644 index 0000000..a73523d Binary files /dev/null and b/docs/diagrams/class-server.png differ diff --git a/docs/diagrams/class-server.puml b/docs/diagrams/class-server.puml deleted file mode 100644 index 9d7eb87..0000000 --- a/docs/diagrams/class-server.puml +++ /dev/null @@ -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 <> { - - 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 <> { - + 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 <> { - + 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 <> { - - activeClients : Map - - 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 - - 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 <> { - + GET /sessions - + GET /users/:id - + GET /users - + GET /session/:sessionId/user - } - - class "sabnzbd.js" as sab_r <> { - + GET /queue - + GET /history - } - - class "sonarr.js" as sonarr_r <> { - + GET /queue - + GET /history - + GET /series/:id - + GET /series - } - - class "radarr.js" as radarr_r <> { - + GET /queue - + GET /history - + GET /movies/:id - + GET /movies - } -} - -package "server/middleware" { - class "requireAuth.js" as requireauth <> { - + 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 <> { - + 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 - + get(key) : any|null - + set(key, value, ttlMs) : void - + invalidate(key) : void - + clear() : void - + getStats() : CacheStats - } - - class "CacheEntry" as ce <> { - + value : any - + expiresAt : number - } - - class "CacheStats" as cs <> { - + entryCount : number - + totalSizeBytes : number - + entries : CacheEntryStats[] - } - - class "Poller" as poller <> { - - POLL_INTERVAL : number - - POLLING_ENABLED : boolean - - polling : boolean - - lastPollTimings : PollTimings|null - - intervalHandle : number|null - -- - + startPoller() : void - + stopPoller() : void - + pollAllServices() : Promise - + getLastPollTimings() : PollTimings|null - -- - - timed(label, fn) : TimedResult - } - - class "PollTimings" as pt <> { - + totalMs : number - + timestamp : string (ISO) - + tasks : { label, ms }[] - } - - class "Config" as config <> { - + getSABnzbdInstances() : Instance[] - + getSonarrInstances() : Instance[] - + getRadarrInstances() : Instance[] - + getQbittorrentInstances() : Instance[] - -- - - parseInstances(envVar, ...) : Instance[] - } - - class "Instance" as inst <> { - + 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 - + makeRequest(endpoint, config) : Promise - + getTorrents() : Promise - } - - class "qbittorrent.js" as qbt_mod <> { - - persistedClients : QBittorrentClient[]|null - -- - + getTorrents() : Promise - + getClients() : QBittorrentClient[] - + mapTorrentToDownload(torrent) : Download - + formatBytes(bytes) : string - + formatSpeed(bps) : string - + formatEta(seconds) : string - } - - class "Logger" as logger <> { - - logFile : WriteStream - + logToFile(message) : void - } - - class "TokenStore" as tokenstore <> { - - 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 <> { - + sanitizeError(err) : string - -- - Redacts: query-param secrets, - auth headers, bearer tokens, - basic-auth URLs - } - - class "TagBadge" as tb <> { - + label : string - + matchedUser : string | null - } - - class "ClientInfo" as ci <> { - + 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 diff --git a/docs/diagrams/component.png b/docs/diagrams/component.png new file mode 100644 index 0000000..41ee079 Binary files /dev/null and b/docs/diagrams/component.png differ diff --git a/docs/diagrams/component.puml b/docs/diagrams/component.puml deleted file mode 100644 index 0d9cc9b..0000000 --- a/docs/diagrams/component.puml +++ /dev/null @@ -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 diff --git a/docs/diagrams/seq-auth.png b/docs/diagrams/seq-auth.png new file mode 100644 index 0000000..fb89f06 Binary files /dev/null and b/docs/diagrams/seq-auth.png differ diff --git a/docs/diagrams/seq-auth.puml b/docs/diagrams/seq-auth.puml deleted file mode 100644 index 8282fe3..0000000 --- a/docs/diagrams/seq-auth.puml +++ /dev/null @@ -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 diff --git a/docs/diagrams/seq-dashboard.png b/docs/diagrams/seq-dashboard.png new file mode 100644 index 0000000..19f0745 Binary files /dev/null and b/docs/diagrams/seq-dashboard.png differ diff --git a/docs/diagrams/seq-dashboard.puml b/docs/diagrams/seq-dashboard.puml deleted file mode 100644 index 15e6578..0000000 --- a/docs/diagrams/seq-dashboard.puml +++ /dev/null @@ -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 diff --git a/docs/diagrams/seq-polling.png b/docs/diagrams/seq-polling.png new file mode 100644 index 0000000..ba4a7e9 Binary files /dev/null and b/docs/diagrams/seq-polling.png differ diff --git a/docs/diagrams/seq-polling.puml b/docs/diagrams/seq-polling.puml deleted file mode 100644 index 7866218..0000000 --- a/docs/diagrams/seq-polling.puml +++ /dev/null @@ -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 diff --git a/docs/diagrams/state-poller.png b/docs/diagrams/state-poller.png new file mode 100644 index 0000000..be30954 Binary files /dev/null and b/docs/diagrams/state-poller.png differ diff --git a/docs/diagrams/state-poller.puml b/docs/diagrams/state-poller.puml deleted file mode 100644 index a4a24e9..0000000 --- a/docs/diagrams/state-poller.puml +++ /dev/null @@ -1,67 +0,0 @@ -@startuml state-poller -!theme plain -title sofarr — Poller State Diagram - -[*] --> CheckConfig : startPoller() - -state CheckConfig <> -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 diff --git a/docs/diagrams/state-ui.png b/docs/diagrams/state-ui.png new file mode 100644 index 0000000..1442d70 Binary files /dev/null and b/docs/diagrams/state-ui.png differ diff --git a/docs/diagrams/state-ui.puml b/docs/diagrams/state-ui.puml deleted file mode 100644 index 6254602..0000000 --- a/docs/diagrams/state-ui.puml +++ /dev/null @@ -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 <> -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