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:
128
docs/diagrams/activity-matching.puml
Normal file
128
docs/diagrams/activity-matching.puml
Normal 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
|
||||
221
docs/diagrams/class-data.puml
Normal file
221
docs/diagrams/class-data.puml
Normal 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
|
||||
197
docs/diagrams/class-server.puml
Normal file
197
docs/diagrams/class-server.puml
Normal 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
|
||||
94
docs/diagrams/component.puml
Normal file
94
docs/diagrams/component.puml
Normal 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
|
||||
67
docs/diagrams/seq-auth.puml
Normal file
67
docs/diagrams/seq-auth.puml
Normal 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
|
||||
85
docs/diagrams/seq-dashboard.puml
Normal file
85
docs/diagrams/seq-dashboard.puml
Normal 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
|
||||
89
docs/diagrams/seq-polling.puml
Normal file
89
docs/diagrams/seq-polling.puml
Normal 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
|
||||
65
docs/diagrams/state-poller.puml
Normal file
65
docs/diagrams/state-poller.puml
Normal 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
|
||||
79
docs/diagrams/state-ui.puml
Normal file
79
docs/diagrams/state-ui.puml
Normal 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
|
||||
Reference in New Issue
Block a user