Server: - poller.js: add pollSubscribers Set with onPollComplete/offPollComplete; notify all SSE callbacks immediately after every successful poll - dashboard.js: add GET /api/dashboard/stream endpoint (text/event-stream) - requireAuth enforced via cookie (no CSRF needed — GET is a safe method) - X-Accel-Buffering: no for nginx proxy compatibility - 25s heartbeat comments to survive proxy idle timeouts - initial payload sent immediately on connect - cleanup on req.close: deregister callback, stop heartbeat, remove client - active client tracking updated: type='sse', connectedAt, no refreshRateMs Frontend: - app.js: replace setInterval/fetchUserDownloads with EventSource - startSSE() opens /api/dashboard/stream; stopSSE() closes it - first incoming message hides loading spinner - showAll toggle re-opens stream with ?showAll=true param - logout calls stopSSE() before POST /api/auth/logout - status panel: fixed 5s refresh, shows SSE clients + connect duration - statusRefreshHandle now always 5s, not tied to old refresh-rate selector - index.html: remove now-unused refresh-rate <select> element Docs: - ARCHITECTURE.md §4.3: update poller description - ARCHITECTURE.md §5: rename to SSE Stream (§5.2) + Download Matching (§5.3) - ARCHITECTURE.md §7: update active client tracking description - ARCHITECTURE.md §9: add /stream endpoint, update /status clients schema - ARCHITECTURE.md §10: update key functions table; replace Auto-Refresh section with Live Push via SSE - class-server.puml: add /stream to dashboard routes; update ClientInfo - component.puml: annotate dashboard with SSE note; update label
279 lines
6.7 KiB
Plaintext
279 lines
6.7 KiB
Plaintext
@startuml class-server
|
|
!theme plain
|
|
title sofarr — Server Class / Module Diagram
|
|
|
|
package "server/index.js" as entry {
|
|
class "EntryPoint" as ep <<module>> {
|
|
- LOG_LEVELS : Object
|
|
- currentLevel : number
|
|
- logFile : WriteStream
|
|
+ shouldLog(level) : boolean
|
|
--
|
|
Logging setup, app.listen(),
|
|
static files, startPoller()
|
|
}
|
|
}
|
|
|
|
package "server/app.js" as appfactory {
|
|
class "createApp(options?)" as appfn <<factory>> {
|
|
+ createApp(skipRateLimits?) : Express
|
|
--
|
|
Mounts helmet (CSP nonce),
|
|
rate limiters, cookie-parser,
|
|
auth routes (pre-CSRF),
|
|
verifyCsrf, all other routes,
|
|
/health, /ready, error handler
|
|
}
|
|
}
|
|
|
|
package "server/routes" {
|
|
class "auth.js" as auth <<router>> {
|
|
+ POST /login (rate-limited)
|
|
+ GET /me
|
|
+ GET /csrf
|
|
+ POST /logout
|
|
--
|
|
Authenticates via Emby API
|
|
Issues emby_user + csrf_token cookies
|
|
Stores/revokes Emby tokens server-side
|
|
}
|
|
|
|
class "dashboard.js" as dashboard <<router>> {
|
|
- activeClients : Map<string, ClientInfo>
|
|
- CLIENT_STALE_MS : 30000
|
|
--
|
|
+ GET /stream (SSE, text/event-stream)
|
|
+ GET /user-downloads
|
|
+ GET /user-summary
|
|
+ GET /status
|
|
+ GET /cover-art
|
|
--
|
|
- getCoverArt(item) : string|null
|
|
- extractAllTags(tags, tagMap) : string[]
|
|
- extractUserTag(tags, tagMap, username) : string|null
|
|
- buildTagBadges(allTags, embyUserMap) : TagBadge[]
|
|
- getEmbyUsers() : Promise<Map>
|
|
- sanitizeTagLabel(input) : string
|
|
- tagMatchesUser(tag, username) : boolean
|
|
- getImportIssues(record) : string[]|null
|
|
- getSonarrLink(series) : string|null
|
|
- getRadarrLink(movie) : string|null
|
|
- getActiveClients() : ClientInfo[]
|
|
}
|
|
|
|
class "emby.js" as emby_r <<router>> {
|
|
+ GET /sessions
|
|
+ GET /users/:id
|
|
+ GET /users
|
|
+ GET /session/:sessionId/user
|
|
}
|
|
|
|
class "sabnzbd.js" as sab_r <<router>> {
|
|
+ GET /queue
|
|
+ GET /history
|
|
}
|
|
|
|
class "sonarr.js" as sonarr_r <<router>> {
|
|
+ GET /queue
|
|
+ GET /history
|
|
+ GET /series/:id
|
|
+ GET /series
|
|
}
|
|
|
|
class "radarr.js" as radarr_r <<router>> {
|
|
+ GET /queue
|
|
+ GET /history
|
|
+ GET /movies/:id
|
|
+ GET /movies
|
|
}
|
|
}
|
|
|
|
package "server/middleware" {
|
|
class "requireAuth.js" as requireauth <<middleware>> {
|
|
+ requireAuth(req, res, next) : void
|
|
--
|
|
Reads emby_user cookie (signed if COOKIE_SECRET)
|
|
Validates schema: id, name, isAdmin
|
|
Attaches user to req.user
|
|
Returns 401 if absent/tampered/invalid
|
|
}
|
|
|
|
class "verifyCsrf.js" as verifycsrf <<middleware>> {
|
|
+ verifyCsrf(req, res, next) : void
|
|
--
|
|
Exempt: GET, HEAD, OPTIONS
|
|
Compares csrf_token cookie
|
|
vs X-CSRF-Token header
|
|
using crypto.timingSafeEqual
|
|
Returns 403 on mismatch/missing
|
|
}
|
|
}
|
|
|
|
package "server/utils" {
|
|
class "MemoryCache" as cache {
|
|
- store : Map<string, CacheEntry>
|
|
+ get(key) : any|null
|
|
+ set(key, value, ttlMs) : void
|
|
+ invalidate(key) : void
|
|
+ clear() : void
|
|
+ getStats() : CacheStats
|
|
}
|
|
|
|
class "CacheEntry" as ce <<value>> {
|
|
+ value : any
|
|
+ expiresAt : number
|
|
}
|
|
|
|
class "CacheStats" as cs <<value>> {
|
|
+ entryCount : number
|
|
+ totalSizeBytes : number
|
|
+ entries : CacheEntryStats[]
|
|
}
|
|
|
|
class "Poller" as poller <<module>> {
|
|
- POLL_INTERVAL : number
|
|
- POLLING_ENABLED : boolean
|
|
- polling : boolean
|
|
- lastPollTimings : PollTimings|null
|
|
- intervalHandle : number|null
|
|
--
|
|
+ startPoller() : void
|
|
+ stopPoller() : void
|
|
+ pollAllServices() : Promise<void>
|
|
+ getLastPollTimings() : PollTimings|null
|
|
--
|
|
- timed(label, fn) : TimedResult
|
|
}
|
|
|
|
class "PollTimings" as pt <<value>> {
|
|
+ totalMs : number
|
|
+ timestamp : string (ISO)
|
|
+ tasks : { label, ms }[]
|
|
}
|
|
|
|
class "Config" as config <<module>> {
|
|
+ getSABnzbdInstances() : Instance[]
|
|
+ getSonarrInstances() : Instance[]
|
|
+ getRadarrInstances() : Instance[]
|
|
+ getQbittorrentInstances() : Instance[]
|
|
--
|
|
- parseInstances(envVar, ...) : Instance[]
|
|
}
|
|
|
|
class "Instance" as inst <<value>> {
|
|
+ id : string
|
|
+ name : string
|
|
+ url : string
|
|
+ apiKey : string
|
|
+ username? : string
|
|
+ password? : string
|
|
}
|
|
|
|
class "QBittorrentClient" as qbt {
|
|
- id : string
|
|
- name : string
|
|
- url : string
|
|
- username : string
|
|
- password : string
|
|
- authCookie : string|null
|
|
--
|
|
+ login() : Promise<boolean>
|
|
+ makeRequest(endpoint, config) : Promise<Response>
|
|
+ getTorrents() : Promise<Torrent[]>
|
|
}
|
|
|
|
class "qbittorrent.js" as qbt_mod <<module>> {
|
|
- persistedClients : QBittorrentClient[]|null
|
|
--
|
|
+ getTorrents() : Promise<Torrent[]>
|
|
+ getClients() : QBittorrentClient[]
|
|
+ mapTorrentToDownload(torrent) : Download
|
|
+ formatBytes(bytes) : string
|
|
+ formatSpeed(bps) : string
|
|
+ formatEta(seconds) : string
|
|
}
|
|
|
|
class "Logger" as logger <<module>> {
|
|
- logFile : WriteStream
|
|
+ logToFile(message) : void
|
|
}
|
|
|
|
class "TokenStore" as tokenstore <<module>> {
|
|
- store : Object (in-memory)
|
|
- STORE_PATH : string (DATA_DIR/tokens.json)
|
|
- TOKEN_TTL_MS : 31 days
|
|
--
|
|
+ storeToken(userId, accessToken) : void
|
|
+ getToken(userId) : {accessToken}|null
|
|
+ clearToken(userId) : void
|
|
--
|
|
Atomic write (.tmp → rename)
|
|
Pruned on startup + hourly
|
|
}
|
|
|
|
class "SanitizeError" as sanitize <<module>> {
|
|
+ sanitizeError(err) : string
|
|
--
|
|
Redacts: query-param secrets,
|
|
auth headers, bearer tokens,
|
|
basic-auth URLs
|
|
}
|
|
|
|
class "TagBadge" as tb <<value>> {
|
|
+ label : string
|
|
+ matchedUser : string | null
|
|
}
|
|
|
|
class "ClientInfo" as ci <<value>> {
|
|
+ user : string
|
|
+ type : 'sse'
|
|
+ connectedAt : number (timestamp)
|
|
+ lastSeen : number (timestamp)
|
|
}
|
|
}
|
|
|
|
' Relationships
|
|
ep --> appfn : createApp()
|
|
ep --> poller : startPoller()
|
|
|
|
appfn --> auth : /api/auth (pre-CSRF)
|
|
appfn --> verifycsrf : /api (all routes below)
|
|
appfn --> dashboard
|
|
appfn --> emby_r
|
|
appfn --> sab_r
|
|
appfn --> sonarr_r
|
|
appfn --> radarr_r
|
|
|
|
dashboard --> requireauth : uses
|
|
emby_r --> requireauth : uses
|
|
sab_r --> requireauth : uses
|
|
sonarr_r --> requireauth : uses
|
|
radarr_r --> requireauth : uses
|
|
|
|
auth --> tokenstore : storeToken / getToken / clearToken
|
|
|
|
dashboard --> cache : read/write
|
|
dashboard --> poller : pollAllServices()
|
|
dashboard --> qbt_mod : mapTorrentToDownload()
|
|
dashboard --> config
|
|
|
|
poller --> cache : set poll:* keys
|
|
poller --> config : get instances
|
|
poller --> qbt_mod : getTorrents()
|
|
|
|
qbt_mod --> config : getQbittorrentInstances()
|
|
qbt_mod *-- qbt : creates
|
|
qbt --> logger
|
|
|
|
cache *-- ce : stores
|
|
cache ..> cs : returns from getStats()
|
|
poller ..> pt : stores/returns
|
|
dashboard *-- ci : stores in activeClients
|
|
|
|
config ..> inst : returns
|
|
|
|
auth ..> sanitize : sanitizeError on catch
|
|
dashboard ..> sanitize : sanitizeError on catch
|
|
|
|
@enduml
|