docs: update architecture docs and diagrams for recent changes
All checks were successful
Build and Push Docker Image / build (push) Successful in 24s

ARCHITECTURE.md:
- Directory structure: add middleware/requireAuth.js and favicon assets
- §4.1: remove CORS from middleware list
- §4.2: all proxy routes now auth-required via requireAuth; add
  middleware description
- §6: cookie payload corrected (no token); document secure+sameSite
- §7: add emby:users cache key (60s TTL)
- §8: Download Object table: userTag → allTags/matchedUserTag/tagBadges
- §9 POST /login: document cookie security attributes
- §10: add Tag Badge Rendering section; remove hardcoded line count

Diagrams:
- class-server.puml: add requireAuth middleware module; update
  dashboard.js methods (extractAllTags, extractUserTag w/ username,
  buildTagBadges, getEmbyUsers); add TagBadge value class; add auth
  relationships for all proxy routes
- class-data.puml: Download Object userTag → allTags/matchedUserTag/
  tagBadges; add TagBadge class; remove token from Session Cookie
- seq-auth.puml: cookie payload no longer contains token; add
  secure/sameSite note
- component.puml: remove CORS component; add requireAuth; consolidate
  Emby connection to show tag badge + user-summary usage
- activity-matching.puml: update to extractAllTags/extractUserTag
  (with username); showAll uses hasAnyTag; tagBadges built from
  embyUserMap; add Emby user fetch step; update legend
- seq-dashboard.puml: add emby:users cache lookup / Emby fetch for
  showAll; update matching groups to show tag classification; add
  tag badge rendering note on renderDownloads()
This commit is contained in:
2026-05-16 15:41:23 +01:00
parent 54647ab7cf
commit 6675e5dcfe
7 changed files with 135 additions and 35 deletions

View File

@@ -107,6 +107,8 @@ sofarr/
│ │ ├── sabnzbd.js # Proxy routes to SABnzbd API │ │ ├── sabnzbd.js # Proxy routes to SABnzbd API
│ │ ├── sonarr.js # Proxy routes to Sonarr API │ │ ├── sonarr.js # Proxy routes to Sonarr API
│ │ └── radarr.js # Proxy routes to Radarr API │ │ └── radarr.js # Proxy routes to Radarr API
│ ├── middleware/
│ │ └── requireAuth.js # httpOnly cookie auth middleware
│ └── utils/ │ └── utils/
│ ├── cache.js # MemoryCache class (Map + TTL + stats) │ ├── cache.js # MemoryCache class (Map + TTL + stats)
│ ├── config.js # Multi-instance service configuration parser │ ├── config.js # Multi-instance service configuration parser
@@ -117,6 +119,9 @@ sofarr/
│ ├── index.html # HTML shell: splash, login, dashboard │ ├── index.html # HTML shell: splash, login, dashboard
│ ├── app.js # All frontend logic (auth, rendering, status) │ ├── app.js # All frontend logic (auth, rendering, status)
│ ├── style.css # Themes, layout, responsive design │ ├── style.css # Themes, layout, responsive design
│ ├── favicon.ico # Multi-size favicon (16/32/48px)
│ ├── favicon-32.png # 32px PNG favicon
│ ├── favicon-192.png # 192px PNG (apple-touch-icon / PWA)
│ └── images/ # Logo / splash screen assets │ └── images/ # Logo / splash screen assets
├── Dockerfile # Production container image ├── Dockerfile # Production container image
├── docker-compose.yaml # Example compose deployment ├── docker-compose.yaml # Example compose deployment
@@ -135,7 +140,7 @@ Responsibilities:
- Load environment variables via `dotenv` - Load environment variables via `dotenv`
- Configure structured logging with level filtering (`LOG_LEVEL`) - Configure structured logging with level filtering (`LOG_LEVEL`)
- Redirect `console.*` to both stdout and `server.log` - Redirect `console.*` to both stdout and `server.log`
- Mount Express middleware (CORS, cookie-parser, JSON, static files) - Mount Express middleware (cookie-parser, JSON, static files)
- Mount route modules under `/api/*` - Mount route modules under `/api/*`
- Start the background poller - Start the background poller
@@ -143,12 +148,14 @@ Responsibilities:
| Module | Mount Point | Auth Required | Purpose | | Module | Mount Point | Auth Required | Purpose |
|--------|------------|---------------|---------| |--------|------------|---------------|---------|
| `auth.js` | `/api/auth` | No | Login, session check, logout | | `auth.js` | `/api/auth` | No (public) | Login, session check, logout |
| `dashboard.js` | `/api/dashboard` | Yes (cookie) | Aggregated download data, status | | `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Aggregated download data, status |
| `emby.js` | `/api/emby` | No | Proxy to Emby API | | `emby.js` | `/api/emby` | Yes (`requireAuth`) | Proxy to Emby API |
| `sabnzbd.js` | `/api/sabnzbd` | No | Proxy to SABnzbd API | | `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Proxy to SABnzbd API |
| `sonarr.js` | `/api/sonarr` | No | Proxy to Sonarr API | | `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Proxy to Sonarr API |
| `radarr.js` | `/api/radarr` | No | Proxy to Radarr API | | `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Proxy to Radarr API |
`requireAuth` (`server/middleware/requireAuth.js`) reads the `emby_user` httpOnly cookie and attaches the parsed user to `req.user`. Returns `401` if the cookie is absent or malformed.
> **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) use legacy single-instance env vars and are largely unused by the dashboard — they exist for direct API access. The dashboard reads from the poller cache. > **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) use legacy single-instance env vars and are largely unused by the dashboard — they exist for direct API access. The dashboard reads from the poller cache.
@@ -208,7 +215,7 @@ When a user requests `/api/dashboard/user-downloads`:
1. User submits credentials via the login form 1. User submits credentials via the login form
2. Backend calls Emby `POST /Users/authenticatebyname` 2. Backend calls Emby `POST /Users/authenticatebyname`
3. On success, fetches full user profile to determine admin status 3. On success, fetches full user profile to determine admin status
4. Sets an `httpOnly` cookie (`emby_user`) containing: `{ id, name, isAdmin, token }` 4. Sets an `httpOnly` cookie (`emby_user`) containing: `{ id, name, isAdmin }` — the Emby `AccessToken` is intentionally **not** stored in the cookie
5. Cookie expires after 24 hours 5. Cookie expires after 24 hours
6. All subsequent dashboard requests read this cookie for identity 6. All subsequent dashboard requests read this cookie for identity
@@ -253,6 +260,7 @@ Users are matched to downloads via tags in Sonarr/Radarr:
| `poll:radarr-history` | `{ records }` — lightweight, no embedded objects | Radarr history | | `poll:radarr-history` | `{ records }` — lightweight, no embedded objects | Radarr history |
| `poll:radarr-tags` | `[{id, label}]` | Radarr tag API | | `poll:radarr-tags` | `[{id, label}]` | Radarr tag API |
| `poll:qbittorrent` | `[torrent, ...]` | qBittorrent all torrents | | `poll:qbittorrent` | `[torrent, ...]` | qBittorrent all torrents |
| `emby:users` | `Map<lowerName, displayName>` | Full Emby user list (60s TTL) |
### TTL Strategy ### TTL Strategy
@@ -314,7 +322,9 @@ Each matched download produces an object with:
| `eta` | string | Estimated time remaining | | `eta` | string | Estimated time remaining |
| `seriesName` / `movieName` | string | Friendly media title | | `seriesName` / `movieName` | string | Friendly media title |
| `episodeInfo` / `movieInfo` | object | Full *arr queue/history record | | `episodeInfo` / `movieInfo` | object | Full *arr queue/history record |
| `userTag` | string | Matched user tag | | `allTags` | string[] | All resolved tag labels on the series/movie |
| `matchedUserTag` | string / null | Tag label matching the requesting user, or `null` |
| `tagBadges` | `{label, matchedUser}[]` / undefined | (Admin `showAll` only) Each tag classified against full Emby user list |
| `importIssues` | string[] / null | Import warning/error messages | | `importIssues` | string[] / null | Import warning/error messages |
| `downloadPath` | string / null | (Admin) Download client path | | `downloadPath` | string / null | (Admin) Download client path |
| `targetPath` | string / null | (Admin) *arr target path | | `targetPath` | string / null | (Admin) *arr target path |
@@ -346,7 +356,7 @@ Authenticate a user via Emby.
{ "success": false, "error": "Invalid username or password" } { "success": false, "error": "Invalid username or password" }
``` ```
**Side Effect:** Sets `emby_user` httpOnly cookie (24h TTL). **Side Effect:** Sets `emby_user` httpOnly cookie (24h TTL, `httpOnly`, `secure` in production, `sameSite: strict`). Cookie payload: `{ id, name, isAdmin }` — Emby `AccessToken` is not stored.
--- ---
@@ -447,7 +457,7 @@ Admin-only per-user download counts (fetches live from APIs, not cached).
## 10. Frontend Architecture ## 10. Frontend Architecture
The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `app.js` (754 lines), styled by `style.css`, and structured by `index.html`. The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `app.js`, styled by `style.css`, and structured by `index.html`.
### UI States ### UI States
@@ -473,7 +483,7 @@ The frontend is a **vanilla JavaScript SPA** with no build step. All logic resid
| `handleLogin()` | Authenticate, fade login → splash → dashboard | | `handleLogin()` | Authenticate, fade login → splash → dashboard |
| `fetchUserDownloads()` | GET `/user-downloads`, update state, re-render | | `fetchUserDownloads()` | GET `/user-downloads`, update state, re-render |
| `renderDownloads()` | Diff-based card rendering (create/update/remove) | | `renderDownloads()` | Diff-based card rendering (create/update/remove) |
| `createDownloadCard()` | Build DOM for a single download card | | `createDownloadCard()` | Build DOM for a single download card; renders tag badges |
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) | | `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
| `toggleStatusPanel()` | Show/hide admin status panel | | `toggleStatusPanel()` | Show/hide admin status panel |
| `renderStatusPanel()` | Build status HTML (server, polling, cache, clients) | | `renderStatusPanel()` | Build status HTML (server, polling, cache, clients) |
@@ -489,6 +499,15 @@ Three CSS themes via `data-theme` attribute on `<html>`:
Theme selection persists in `localStorage`. Theme selection persists in `localStorage`.
### Tag Badge Rendering
Download cards render tag badges in the card header:
- **Normal user view**: a single accent-coloured badge showing the tag label that matched the current user's username (via `matchedUserTag`).
- **Admin `showAll` view**: all tags on the download are rendered using `tagBadges[]`:
- Tags with **no matching Emby user** → amber badge showing the raw tag label (leftmost)
- Tags **matched to a known Emby user** → accent badge showing the Emby display name (rightmost)
### Auto-Refresh ### Auto-Refresh
The dashboard polls the server at the user-selected interval (1s, 5s, 10s, or Off). The status panel auto-refreshes in sync. Client refresh rate is sent to the server on each request for active-client tracking. The dashboard polls the server at the user-selected interval (1s, 5s, 10s, or Off). The status panel auto-refreshes in sync. Client refresh rate is sent to the server on each request for active-client tracking.

View File

@@ -22,6 +22,12 @@ end note
:Build **sonarrTagMap** (tagId → label) :Build **sonarrTagMap** (tagId → label)
Build **radarrTagMap** (tagId → label); Build **radarrTagMap** (tagId → label);
if (showAll?) then (yes)
:Fetch full Emby user list
Build **embyUserMap** (lowerName → displayName)
[cached 60s];
endif
:Initialise **userDownloads** = []; :Initialise **userDownloads** = [];
partition "Process SABnzbd Queue Slots" { partition "Process SABnzbd Queue Slots" {
@@ -32,13 +38,20 @@ partition "Process SABnzbd Queue Slots" {
if (Title matches Sonarr **queue** record?) then (yes) if (Title matches Sonarr **queue** record?) then (yes)
:series = seriesMap.get(match.seriesId)\n|| match.series; :series = seriesMap.get(match.seriesId)\n|| match.series;
if (series exists?) then (yes) if (series exists?) then (yes)
:userTag = extractUserTag(series.tags, sonarrTagMap); :allTags = extractAllTags(series.tags, sonarrTagMap)
if (showAll OR tagMatchesUser?) then (yes) matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
if (showAll AND hasAnyTag?) then (yes)
:Build download object (type=series) :Build download object (type=series)
Add coverArt, status, progress, speed, eta Add coverArt, status, progress, speed, eta
Add allTags, matchedUserTag
Add tagBadges = buildTagBadges(allTags, embyUserMap)
Add importIssues if any Add importIssues if any
Add admin fields (paths, arrLink); Add admin fields (paths, arrLink);
:Push to **userDownloads**; :Push to **userDownloads**;
elseif (NOT showAll AND matchedUserTag?) then (yes)
:Build download object (type=series)
Add matchedUserTag;
:Push to **userDownloads**;
endif endif
endif endif
endif endif
@@ -46,13 +59,19 @@ partition "Process SABnzbd Queue Slots" {
if (Title matches Radarr **queue** record?) then (yes) if (Title matches Radarr **queue** record?) then (yes)
:movie = moviesMap.get(match.movieId)\n|| match.movie; :movie = moviesMap.get(match.movieId)\n|| match.movie;
if (movie exists?) then (yes) if (movie exists?) then (yes)
:userTag = extractUserTag(movie.tags, radarrTagMap); :allTags = extractAllTags(movie.tags, radarrTagMap)
if (showAll OR tagMatchesUser?) then (yes) matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
if (showAll AND hasAnyTag?) then (yes)
:Build download object (type=movie) :Build download object (type=movie)
Add coverArt, status, progress, speed, eta Add coverArt, status, progress, speed, eta
Add allTags, matchedUserTag, tagBadges
Add importIssues if any Add importIssues if any
Add admin fields (paths, arrLink); Add admin fields (paths, arrLink);
:Push to **userDownloads**; :Push to **userDownloads**;
elseif (NOT showAll AND matchedUserTag?) then (yes)
:Build download object (type=movie)
Add matchedUserTag;
:Push to **userDownloads**;
endif endif
endif endif
endif endif
@@ -67,16 +86,20 @@ partition "Process SABnzbd History Slots" {
if (Title matches Sonarr **history** record?) then (yes) if (Title matches Sonarr **history** record?) then (yes)
:series = seriesMap.get(match.seriesId)\n|| match.series; :series = seriesMap.get(match.seriesId)\n|| match.series;
if (series found?) then (yes) if (series found?) then (yes)
:Check user tag, build download\n(type=series, with completedAt); :extractAllTags + extractUserTag(username)
:Push to **userDownloads** if tag matches; Build download (type=series, completedAt)
Add allTags, matchedUserTag, tagBadges if showAll;
:Push to **userDownloads** if showAll+anyTag or matchedUserTag;
endif endif
endif endif
if (Title matches Radarr **history** record?) then (yes) if (Title matches Radarr **history** record?) then (yes)
:movie = moviesMap.get(match.movieId)\n|| match.movie; :movie = moviesMap.get(match.movieId)\n|| match.movie;
if (movie found?) then (yes) if (movie found?) then (yes)
:Check user tag, build download\n(type=movie, with completedAt); :extractAllTags + extractUserTag(username)
:Push to **userDownloads** if tag matches; Build download (type=movie, completedAt)
Add allTags, matchedUserTag, tagBadges if showAll;
:Push to **userDownloads** if showAll+anyTag or matchedUserTag;
endif endif
endif endif
endwhile (no) endwhile (no)
@@ -119,10 +142,15 @@ legend right
(bidirectional substring, case-insensitive): (bidirectional substring, case-insensitive):
""rTitle.includes(dlTitle) || dlTitle.includes(rTitle)"" ""rTitle.includes(dlTitle) || dlTitle.includes(rTitle)""
**Tag Matching Logic**: **Tag Matching Logic** (tagMatchesUser):
1. Exact: tag.toLowerCase() === username 1. Exact: tag.toLowerCase() === username
2. Sanitised: sanitizeTagLabel(tag) === sanitizeTagLabel(username) 2. Sanitised: sanitizeTagLabel(tag) === sanitizeTagLabel(username)
(handles Ombi-mangled email-style usernames) (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 end legend
@enduml @enduml

View File

@@ -153,7 +153,9 @@ package "sofarr Internal Models" {
+ movieName : string | null + movieName : string | null
+ episodeInfo : object | null + episodeInfo : object | null
+ movieInfo : object | null + movieInfo : object | null
+ userTag : string + allTags : string[]
+ matchedUserTag : string | null
+ tagBadges : TagBadge[] | undefined
+ importIssues : string[] | null + importIssues : string[] | null
+ downloadPath : string | null + downloadPath : string | null
+ targetPath : string | null + targetPath : string | null
@@ -170,6 +172,11 @@ package "sofarr Internal Models" {
+ completedAt : string + completedAt : string
} }
class "TagBadge" as tagbadge <<value>> {
+ label : string
+ matchedUser : string | null
}
class "API Response\n/user-downloads" as apir { class "API Response\n/user-downloads" as apir {
+ user : string + user : string
+ isAdmin : boolean + isAdmin : boolean
@@ -201,7 +208,7 @@ package "sofarr Internal Models" {
+ id : string + id : string
+ name : string + name : string
+ isAdmin : boolean + isAdmin : boolean
+ token : string ' Note: Emby AccessToken intentionally excluded
} }
apir *-- dl apir *-- dl
@@ -215,7 +222,9 @@ sabh ..> dl : matched &\ntransformed
qbt ..> dl : mapTorrentToDownload() qbt ..> dl : mapTorrentToDownload()
ss ..> dl : coverArt, seriesName,\npath, tags ss ..> dl : coverArt, seriesName,\npath, tags
rm ..> dl : coverArt, movieName,\npath, tags rm ..> dl : coverArt, movieName,\npath, tags
tag ..> dl : userTag resolution tag ..> dl : allTags / matchedUserTag
eu ..> cookie : login creates eu ..> cookie : login creates
eu ..> tagbadge : buildTagBadges()
dl *-- tagbadge : tagBadges[]
@enduml @enduml

View File

@@ -33,7 +33,10 @@ package "server/routes" {
+ GET /status + GET /status
-- --
- getCoverArt(item) : string|null - getCoverArt(item) : string|null
- extractUserTag(tags, tagMap) : string|null - extractAllTags(tags, tagMap) : string[]
- extractUserTag(tags, tagMap, username) : string|null
- buildTagBadges(allTags, embyUserMap) : TagBadge[]
- getEmbyUsers() : Promise<Map>
- sanitizeTagLabel(input) : string - sanitizeTagLabel(input) : string
- tagMatchesUser(tag, username) : boolean - tagMatchesUser(tag, username) : boolean
- getImportIssues(record) : string[]|null - getImportIssues(record) : string[]|null
@@ -69,6 +72,16 @@ package "server/routes" {
} }
} }
package "server/middleware" {
class "requireAuth.js" as requireauth <<middleware>> {
+ requireAuth(req, res, next) : void
--
Reads emby_user cookie
Attaches parsed user to req.user
Returns 401 if absent/invalid
}
}
package "server/utils" { package "server/utils" {
class "MemoryCache" as cache { class "MemoryCache" as cache {
- store : Map<string, CacheEntry> - store : Map<string, CacheEntry>
@@ -158,6 +171,11 @@ package "server/utils" {
+ logToFile(message) : void + logToFile(message) : void
} }
class "TagBadge" as tb <<value>> {
+ label : string
+ matchedUser : string | null
}
class "ClientInfo" as ci <<value>> { class "ClientInfo" as ci <<value>> {
+ user : string + user : string
+ refreshRateMs : number + refreshRateMs : number
@@ -172,6 +190,12 @@ ep --> emby_r
ep --> sab_r ep --> sab_r
ep --> sonarr_r ep --> sonarr_r
ep --> radarr_r ep --> radarr_r
dashboard --> requireauth : uses
emby_r --> requireauth : uses
sab_r --> requireauth : uses
sonarr_r --> requireauth : uses
radarr_r --> requireauth : uses
ep --> poller : startPoller() ep --> poller : startPoller()
dashboard --> cache : read/write dashboard --> cache : read/write

View File

@@ -16,10 +16,10 @@ package "Browser" as browser {
package "Express Server" as server { package "Express Server" as server {
package "Middleware" { package "Middleware" {
[CORS] as cors
[cookie-parser] as cp [cookie-parser] as cp
[express.json] as ej [express.json] as ej
[express.static] as es [express.static] as es
[requireAuth.js] as requireauth
} }
package "Routes" as routes { package "Routes" as routes {
@@ -41,7 +41,6 @@ package "Express Server" as server {
[index.js\nEntry Point] as entry [index.js\nEntry Point] as entry
entry --> cors
entry --> cp entry --> cp
entry --> ej entry --> ej
entry --> es entry --> es
@@ -51,6 +50,12 @@ package "Express Server" as server {
entry --> sab_route entry --> sab_route
entry --> sonarr_route entry --> sonarr_route
entry --> radarr_route entry --> radarr_route
emby_route --> requireauth
sab_route --> requireauth
sonarr_route --> requireauth
radarr_route --> requireauth
dashboard --> requireauth
entry --> poller : startPoller() entry --> poller : startPoller()
dashboard --> cache : read poll:* keys dashboard --> cache : read poll:* keys
@@ -76,7 +81,7 @@ cloud "External Services" as external {
} }
auth --> emby : authenticate\nuser profile auth --> emby : authenticate\nuser profile
dashboard ..> emby : /user-summary\n(live fetch) dashboard --> emby : GET /Users\n(user-summary + tag badge classification)
emby_route --> emby emby_route --> emby
sab_route --> sab sab_route --> sab
sonarr_route --> sonarr sonarr_route --> sonarr

View File

@@ -37,7 +37,7 @@ alt Valid credentials
auth -> emby : GET /Users/{userId} auth -> emby : GET /Users/{userId}
emby --> auth : { Name, Policy: { IsAdministrator } } emby --> auth : { Name, Policy: { IsAdministrator } }
deactivate emby deactivate emby
auth -> auth : Set httpOnly cookie\nemby_user = { id, name, isAdmin, token }\n(24h TTL) auth -> auth : Set httpOnly cookie\nemby_user = { id, name, isAdmin }\n(24h TTL, secure in prod, sameSite=strict)\nNote: AccessToken NOT stored
auth --> browser : { success: true, user: { name, isAdmin } } auth --> browser : { success: true, user: { name, isAdmin } }
browser -> browser : fadeOutLogin() browser -> browser : fadeOutLogin()
browser -> browser : showSplash() browser -> browser : showSplash()

View File

@@ -50,19 +50,28 @@ dashboard -> dashboard : Build seriesMap from\nSonarr queue records
dashboard -> dashboard : Build moviesMap from\nRadarr queue records dashboard -> dashboard : Build moviesMap from\nRadarr queue records
dashboard -> dashboard : Build tag maps\n(id → label) dashboard -> dashboard : Build tag maps\n(id → label)
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
dashboard -> dashboard : Build embyUserMap\n(lowerName → displayName)
end
group SABnzbd Queue Matching group SABnzbd Queue Matching
loop each queue slot loop each queue slot
dashboard -> dashboard : Match title vs Sonarr queue dashboard -> dashboard : Match title vs Sonarr queue
dashboard -> dashboard : Match title vs Radarr queue dashboard -> dashboard : Match title vs Radarr queue
dashboard -> dashboard : Resolve series/movie\n→ extract user tag\n→ filter by username dashboard -> dashboard : extractAllTags() + extractUserTag(username)\nInclude if: showAll+anyTag OR matchedUserTag\nAttach allTags, matchedUserTag\nIf showAll: tagBadges = buildTagBadges(embyUserMap)
end end
end end
group SABnzbd History Matching group SABnzbd History Matching
loop each history slot loop each history slot
dashboard -> dashboard : Match title vs Sonarr history dashboard -> dashboard : Match title vs Sonarr/Radarr history
dashboard -> dashboard : Match title vs Radarr history dashboard -> dashboard : Same tag extraction + inclusion logic
dashboard -> dashboard : Resolve via seriesMap/moviesMap\n→ extract user tag\n→ filter
end end
end end
@@ -72,14 +81,20 @@ group qBittorrent Matching
dashboard -> dashboard : 2. Match vs Radarr queue dashboard -> dashboard : 2. Match vs Radarr queue
dashboard -> dashboard : 3. Match vs Sonarr history dashboard -> dashboard : 3. Match vs Sonarr history
dashboard -> dashboard : 4. Match vs Radarr history dashboard -> dashboard : 4. Match vs Radarr history
dashboard -> dashboard : mapTorrentToDownload()\n→ enrich with series/movie info dashboard -> dashboard : mapTorrentToDownload()\n→ enrich: allTags, matchedUserTag, tagBadges
end end
end end
dashboard --> browser : { user, isAdmin,\ndownloads: [...] } dashboard --> browser : { user, isAdmin,\ndownloads: [...] }
deactivate dashboard deactivate dashboard
browser -> browser : renderDownloads()\n(diff-based update) browser -> browser : renderDownloads() (diff-based)
note right
createDownloadCard() renders tag badges:
- Normal: accent badge for matchedUserTag
- showAll: amber badges (unmatched tags)
accent badges (matched → show Emby displayName)
end note
deactivate browser deactivate browser
@enduml @enduml