docs: comprehensive architecture documentation with PlantUML diagrams

- docs/ARCHITECTURE.md: full system overview, technology stack, directory
  structure, component architecture, data flow, auth, polling/caching,
  download matching pipeline, API reference, frontend architecture,
  configuration, deployment guide
- docs/diagrams/component.puml: system component diagram
- docs/diagrams/seq-auth.puml: authentication sequence diagram
- docs/diagrams/seq-dashboard.puml: dashboard request sequence diagram
- docs/diagrams/seq-polling.puml: background polling cycle sequence
- docs/diagrams/class-server.puml: server-side class/module diagram
- docs/diagrams/class-data.puml: data model / entity diagram
- docs/diagrams/state-ui.puml: frontend UI state diagram
- docs/diagrams/state-poller.puml: poller state diagram
- docs/diagrams/activity-matching.puml: download matching activity diagram
This commit is contained in:
2026-05-16 00:30:38 +01:00
parent 0c8d5d8a4a
commit e97bd3c67b
10 changed files with 1634 additions and 0 deletions

View File

@@ -0,0 +1,128 @@
@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);
: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)
:userTag = extractUserTag(series.tags, sonarrTagMap);
if (showAll OR tagMatchesUser?) then (yes)
:Build download object (type=series)
Add coverArt, status, progress, speed, eta
Add importIssues if any
Add admin fields (paths, arrLink);
: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)
:userTag = extractUserTag(movie.tags, radarrTagMap);
if (showAll OR tagMatchesUser?) then (yes)
:Build download object (type=movie)
Add coverArt, status, progress, speed, eta
Add importIssues if any
Add admin fields (paths, arrLink);
: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)
:Check user tag, build download\n(type=series, with completedAt);
:Push to **userDownloads** if tag matches;
endif
endif
if (Title matches Radarr **history** record?) then (yes)
:movie = moviesMap.get(match.movieId)\n|| match.movie;
if (movie found?) then (yes)
:Check user tag, build download\n(type=movie, with completedAt);
:Push to **userDownloads** if tag matches;
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**:
1. Exact: tag.toLowerCase() === username
2. Sanitised: sanitizeTagLabel(tag) === sanitizeTagLabel(username)
(handles Ombi-mangled email-style usernames)
end legend
@enduml

View File

@@ -0,0 +1,221 @@
@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
+ userTag : string
+ 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 "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
+ token : string
}
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 : userTag resolution
eu ..> cookie : login creates
@enduml

View File

@@ -0,0 +1,197 @@
@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
--
Configures Express app,
mounts routes, starts poller
}
}
package "server/routes" {
class "auth.js" as auth <<router>> {
+ POST /login
+ GET /me
+ POST /logout
--
Authenticates via Emby API
Sets/reads httpOnly cookie
}
class "dashboard.js" as dashboard <<router>> {
- activeClients : Map<string, ClientInfo>
- CLIENT_STALE_MS : 30000
--
+ GET /user-downloads
+ GET /user-summary
+ GET /status
--
- getCoverArt(item) : string|null
- extractUserTag(tags, tagMap) : string|null
- 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/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 "ClientInfo" as ci <<value>> {
+ user : string
+ refreshRateMs : number
+ lastSeen : number (timestamp)
}
}
' Relationships
ep --> auth
ep --> dashboard
ep --> emby_r
ep --> sab_r
ep --> sonarr_r
ep --> radarr_r
ep --> poller : startPoller()
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
@enduml

View File

@@ -0,0 +1,94 @@
@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 {
package "Middleware" {
[CORS] as cors
[cookie-parser] as cp
[express.json] as ej
[express.static] as es
}
package "Routes" as routes {
[auth.js\n/api/auth] as auth
[dashboard.js\n/api/dashboard] 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
[logger.js] as logger
}
[index.js\nEntry Point] as entry
entry --> cors
entry --> cp
entry --> ej
entry --> es
entry --> auth
entry --> dashboard
entry --> emby_route
entry --> sab_route
entry --> sonarr_route
entry --> radarr_route
entry --> poller : startPoller()
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
}
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 : /user-summary\n(live fetch)
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

View File

@@ -0,0 +1,67 @@
@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 "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
alt Cookie exists and valid
auth --> browser : { authenticated: true, user: { name, isAdmin } }
browser -> browser : showDashboard()
browser -> browser : fetchUserDownloads(true)
browser -> browser : startAutoRefresh()
browser -> browser : dismissSplash()
else No cookie
auth --> browser : { authenticated: false }
browser -> browser : dismissSplash()
browser -> browser : showLogin()
end
deactivate auth
== Login ==
user -> browser : Enter username + password
browser -> auth : POST /api/auth/login\n{ username, password }
activate auth
auth -> emby : POST /Users/authenticatebyname\n{ Username, Pw }
activate emby
alt Valid credentials
emby --> auth : { User: { Id, ... }, AccessToken }
auth -> emby : GET /Users/{userId}
emby --> auth : { Name, Policy: { IsAdministrator } }
deactivate emby
auth -> auth : Set httpOnly cookie\nemby_user = { id, name, isAdmin, token }\n(24h TTL)
auth --> browser : { success: true, user: { name, isAdmin } }
browser -> browser : fadeOutLogin()
browser -> browser : showSplash()
browser -> browser : showDashboard()
browser -> browser : fetchUserDownloads(true)
browser -> browser : startAutoRefresh()
browser -> browser : dismissSplash()
else Invalid credentials
emby --> auth : 401 Error
deactivate emby
auth --> browser : { success: false, error: "Invalid..." }
browser -> browser : showLoginError()
end
deactivate auth
== Logout ==
user -> browser : Click Logout
browser -> browser : stopAutoRefresh()
browser -> auth : POST /api/auth/logout
activate auth
auth -> auth : Clear emby_user cookie
auth --> browser : { success: true }
deactivate auth
browser -> browser : showLogin()
deactivate browser
@enduml

View File

@@ -0,0 +1,85 @@
@startuml seq-dashboard
!theme plain
title sofarr — Dashboard Request 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
== Periodic Refresh (or Initial Load) ==
user -> browser : (auto-refresh fires)
activate browser
browser -> dashboard : GET /api/dashboard/user-downloads\n?refreshRate=5000&showAll=false
activate dashboard
dashboard -> dashboard : Parse emby_user cookie\nExtract username, isAdmin
dashboard -> dashboard : Track client refresh rate\nin activeClients Map
alt Polling disabled AND cache empty
dashboard -> poller : pollAllServices()
activate poller
poller -> ext : Parallel API calls\n(SAB, Sonarr, Radarr, qBit)
ext --> poller : Raw data
poller -> cache : set poll:* keys\n(TTL = 30s)
deactivate poller
end
dashboard -> cache : get('poll:sab-queue')
cache --> dashboard : { slots, status, speed }
dashboard -> cache : get('poll:sab-history')
cache --> dashboard : { slots }
dashboard -> cache : get('poll:sonarr-tags')
cache --> dashboard : [{ instance, data }]
dashboard -> cache : get('poll:sonarr-queue')
cache --> dashboard : { records } (with embedded series)
dashboard -> cache : get('poll:sonarr-history')
cache --> dashboard : { records }
dashboard -> cache : get('poll:radarr-queue')
cache --> dashboard : { records } (with embedded movie)
dashboard -> cache : get('poll:radarr-history')
cache --> dashboard : { records }
dashboard -> cache : get('poll:radarr-tags')
cache --> dashboard : [{id, label}]
dashboard -> cache : get('poll:qbittorrent')
cache --> dashboard : [torrent, ...]
dashboard -> dashboard : Build seriesMap from\nSonarr queue records
dashboard -> dashboard : Build moviesMap from\nRadarr queue records
dashboard -> dashboard : Build tag maps\n(id → label)
group SABnzbd Queue Matching
loop each queue slot
dashboard -> dashboard : Match title vs Sonarr queue
dashboard -> dashboard : Match title vs Radarr queue
dashboard -> dashboard : Resolve series/movie\n→ extract user tag\n→ filter by username
end
end
group SABnzbd History Matching
loop each history slot
dashboard -> dashboard : Match title vs Sonarr history
dashboard -> dashboard : Match title vs Radarr history
dashboard -> dashboard : Resolve via seriesMap/moviesMap\n→ extract user tag\n→ filter
end
end
group qBittorrent Matching
loop each torrent
dashboard -> dashboard : 1. Match vs Sonarr queue
dashboard -> dashboard : 2. Match vs Radarr queue
dashboard -> dashboard : 3. Match vs Sonarr history
dashboard -> dashboard : 4. Match vs Radarr history
dashboard -> dashboard : mapTorrentToDownload()\n→ enrich with series/movie info
end
end
dashboard --> browser : { user, isAdmin,\ndownloads: [...] }
deactivate dashboard
browser -> browser : renderDownloads()\n(diff-based update)
deactivate browser
@enduml

View File

@@ -0,0 +1,89 @@
@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 : polling = false\nlog elapsed time
deactivate poller
@enduml

View File

@@ -0,0 +1,65 @@
@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 --> 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

View File

@@ -0,0 +1,79 @@
@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 "fetchUserDownloads()" as fetching
}
SplashScreen2 --> Dashboard : Data loaded\ndismissSplash()
state Dashboard {
state "Rendering Cards" as rendering
state "Auto Refreshing" as refreshing
state "Status Panel Open" as status_open
state "Status Panel Closed" as status_closed
[*] --> rendering
rendering --> refreshing : startAutoRefresh()
refreshing --> rendering : fetchUserDownloads()\n→ renderDownloads()
rendering --> rendering : Theme change
status_closed --> status_open : Click "Status" btn\n(admin only)
status_open --> status_closed : Click close (×)
status_open --> status_open : Auto-refresh\nrenderStatusPanel()
[*] --> status_closed
state "Refresh Rate" as rr {
state "1s" as r1
state "5s (default)" as r5
state "10s" as r10
state "Off" as roff
r5 --> r1 : User selects
r5 --> r10
r5 --> roff
r1 --> r5
r1 --> r10
r1 --> roff
r10 --> r1
r10 --> r5
r10 --> roff
roff --> r1
roff --> r5
roff --> r10
}
}
Dashboard --> LoginForm : Logout\n(clear cookie,\nstopAutoRefresh)
@enduml