Merge develop into main for v0.1.5
All checks were successful
Build and Push Docker Image / build (push) Successful in 24s
CI / npm audit (push) Successful in 43s
Create Release / release (push) Successful in 15s

This commit is contained in:
2026-05-16 17:18:11 +01:00
30 changed files with 729 additions and 362 deletions

View File

@@ -6,6 +6,7 @@ node_modules/
.gitignore .gitignore
.DS_Store .DS_Store
*.log *.log
**/*.log
client/ client/
dist/ dist/
build/ build/
@@ -13,3 +14,4 @@ README.md
.dockerignore .dockerignore
Dockerfile Dockerfile
.gitea/ .gitea/
docs/

View File

@@ -2,6 +2,10 @@
PORT=3001 PORT=3001
LOG_LEVEL=info LOG_LEVEL=info
# Cookie signing secret for tamper-proof session cookies
# Required in production. Generate with: openssl rand -hex 32
COOKIE_SECRET=your_cookie_secret_here
# Background polling interval in ms (default: 5000) # Background polling interval in ms (default: 5000)
# Set to 0 or "off" to disable and fetch on-demand instead # Set to 0 or "off" to disable and fetch on-demand instead
# POLL_INTERVAL=5000 # POLL_INTERVAL=5000

View File

@@ -14,6 +14,11 @@ PORT=3001
# - silent: No logging # - silent: No logging
LOG_LEVEL=info LOG_LEVEL=info
# Cookie signing secret for tamper-proof session cookies
# Required in production (server exits on startup if unset).
# Generate with: openssl rand -hex 32
COOKIE_SECRET=your-cookie-secret-here
# Background polling interval in milliseconds (default: 5000) # Background polling interval in milliseconds (default: 5000)
# sofarr polls all services in the background and caches results so # sofarr polls all services in the background and caches results so
# dashboard requests are near-instant. # dashboard requests are near-instant.

26
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,26 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
audit:
name: npm audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run security audit
run: npm audit --audit-level=moderate

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ dist/
build/ build/
.DS_Store .DS_Store
*.log *.log
**/*.log

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

253
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "media-download-dashboard", "name": "sofarr",
"version": "1.0.0", "version": "0.1.4",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "media-download-dashboard", "name": "sofarr",
"version": "1.0.0", "version": "0.1.4",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",
@@ -14,11 +14,12 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"node-cron": "^3.0.3" "express-rate-limit": "^6.7.0",
"helmet": "^4.6.0"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^7.6.0", "concurrently": "^7.6.0",
"nodemon": "^2.0.22" "nodemon": "^3.1.14"
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
@@ -133,10 +134,13 @@
} }
}, },
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true "dev": true,
"engines": {
"node": "18 || 20 || >=22"
}
}, },
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
@@ -174,13 +178,15 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.14", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^4.0.2"
"concat-map": "0.0.1" },
"engines": {
"node": "18 || 20 || >=22"
} }
}, },
"node_modules/braces": { "node_modules/braces": {
@@ -325,12 +331,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true
},
"node_modules/concurrently": { "node_modules/concurrently": {
"version": "7.6.0", "version": "7.6.0",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-7.6.0.tgz", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-7.6.0.tgz",
@@ -623,6 +623,17 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.7.0.tgz",
"integrity": "sha512-vhwIdRoqcYB/72TK3tRZI+0ttS8Ytrk24GfmsxDXK9o9IhHNO5bXRiXQSExPQ4GbaE5tvIS7j1SGrxsuWs+sGA==",
"engines": {
"node": ">= 12.9.0"
},
"peerDependencies": {
"express": "^4 || ^5"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -836,6 +847,14 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/helmet": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-4.6.0.tgz",
"integrity": "sha512-HVqALKZlR95ROkrnesdhbbZJFi/rIVSoNq6f3jA/9u6MIbTsPh3xZwihjeI5+DO/2sOV6HMHooXcEOuwskHpTg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1038,15 +1057,18 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.5", "version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^5.0.5"
}, },
"engines": { "engines": {
"node": "*" "node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/ms": { "node_modules/ms": {
@@ -1062,30 +1084,19 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-cron": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
"dependencies": {
"uuid": "8.3.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": { "node_modules/nodemon": {
"version": "2.0.22", "version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
"integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==", "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"chokidar": "^3.5.2", "chokidar": "^3.5.2",
"debug": "^3.2.7", "debug": "^4",
"ignore-by-default": "^1.0.1", "ignore-by-default": "^1.0.1",
"minimatch": "^3.1.2", "minimatch": "^10.2.1",
"pstree.remy": "^1.1.8", "pstree.remy": "^1.1.8",
"semver": "^5.7.1", "semver": "^7.5.3",
"simple-update-notifier": "^1.0.7", "simple-update-notifier": "^2.0.0",
"supports-color": "^5.5.0", "supports-color": "^5.5.0",
"touch": "^3.1.0", "touch": "^3.1.0",
"undefsafe": "^2.0.5" "undefsafe": "^2.0.5"
@@ -1094,7 +1105,7 @@
"nodemon": "bin/nodemon.js" "nodemon": "bin/nodemon.js"
}, },
"engines": { "engines": {
"node": ">=8.10.0" "node": ">=10"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -1102,12 +1113,20 @@
} }
}, },
"node_modules/nodemon/node_modules/debug": { "node_modules/nodemon/node_modules/debug": {
"version": "3.2.7", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"ms": "^2.1.1" "ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
} }
}, },
"node_modules/nodemon/node_modules/has-flag": { "node_modules/nodemon/node_modules/has-flag": {
@@ -1318,12 +1337,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "5.7.2", "version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"dev": true, "dev": true,
"bin": { "bin": {
"semver": "bin/semver" "semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
} }
}, },
"node_modules/send": { "node_modules/send": {
@@ -1454,24 +1476,15 @@
} }
}, },
"node_modules/simple-update-notifier": { "node_modules/simple-update-notifier": {
"version": "1.1.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
"integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"semver": "~7.0.0" "semver": "^7.5.3"
}, },
"engines": { "engines": {
"node": ">=8.10.0" "node": ">=10"
}
},
"node_modules/simple-update-notifier/node_modules/semver": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz",
"integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
} }
}, },
"node_modules/spawn-command": { "node_modules/spawn-command": {
@@ -1607,15 +1620,6 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -1764,9 +1768,9 @@
} }
}, },
"balanced-match": { "balanced-match": {
"version": "1.0.2", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true "dev": true
}, },
"binary-extensions": { "binary-extensions": {
@@ -1795,13 +1799,12 @@
} }
}, },
"brace-expansion": { "brace-expansion": {
"version": "1.1.14", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true, "dev": true,
"requires": { "requires": {
"balanced-match": "^1.0.0", "balanced-match": "^4.0.2"
"concat-map": "0.0.1"
} }
}, },
"braces": { "braces": {
@@ -1907,12 +1910,6 @@
"delayed-stream": "~1.0.0" "delayed-stream": "~1.0.0"
} }
}, },
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true
},
"concurrently": { "concurrently": {
"version": "7.6.0", "version": "7.6.0",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-7.6.0.tgz", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-7.6.0.tgz",
@@ -2124,6 +2121,12 @@
"vary": "~1.1.2" "vary": "~1.1.2"
} }
}, },
"express-rate-limit": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.7.0.tgz",
"integrity": "sha512-vhwIdRoqcYB/72TK3tRZI+0ttS8Ytrk24GfmsxDXK9o9IhHNO5bXRiXQSExPQ4GbaE5tvIS7j1SGrxsuWs+sGA==",
"requires": {}
},
"fill-range": { "fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -2259,6 +2262,11 @@
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
} }
}, },
"helmet": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-4.6.0.tgz",
"integrity": "sha512-HVqALKZlR95ROkrnesdhbbZJFi/rIVSoNq6f3jA/9u6MIbTsPh3xZwihjeI5+DO/2sOV6HMHooXcEOuwskHpTg=="
},
"http-errors": { "http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -2400,12 +2408,12 @@
} }
}, },
"minimatch": { "minimatch": {
"version": "3.1.5", "version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true, "dev": true,
"requires": { "requires": {
"brace-expansion": "^1.1.7" "brace-expansion": "^5.0.5"
} }
}, },
"ms": { "ms": {
@@ -2418,39 +2426,31 @@
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="
}, },
"node-cron": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
"requires": {
"uuid": "8.3.2"
}
},
"nodemon": { "nodemon": {
"version": "2.0.22", "version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
"integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==", "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
"dev": true, "dev": true,
"requires": { "requires": {
"chokidar": "^3.5.2", "chokidar": "^3.5.2",
"debug": "^3.2.7", "debug": "^4",
"ignore-by-default": "^1.0.1", "ignore-by-default": "^1.0.1",
"minimatch": "^3.1.2", "minimatch": "^10.2.1",
"pstree.remy": "^1.1.8", "pstree.remy": "^1.1.8",
"semver": "^5.7.1", "semver": "^7.5.3",
"simple-update-notifier": "^1.0.7", "simple-update-notifier": "^2.0.0",
"supports-color": "^5.5.0", "supports-color": "^5.5.0",
"touch": "^3.1.0", "touch": "^3.1.0",
"undefsafe": "^2.0.5" "undefsafe": "^2.0.5"
}, },
"dependencies": { "dependencies": {
"debug": { "debug": {
"version": "3.2.7", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true, "dev": true,
"requires": { "requires": {
"ms": "^2.1.1" "ms": "^2.1.3"
} }
}, },
"has-flag": { "has-flag": {
@@ -2595,9 +2595,9 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
}, },
"semver": { "semver": {
"version": "5.7.2", "version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"dev": true "dev": true
}, },
"send": { "send": {
@@ -2694,20 +2694,12 @@
} }
}, },
"simple-update-notifier": { "simple-update-notifier": {
"version": "1.1.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
"integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
"dev": true, "dev": true,
"requires": { "requires": {
"semver": "~7.0.0" "semver": "^7.5.3"
},
"dependencies": {
"semver": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz",
"integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==",
"dev": true
}
} }
}, },
"spawn-command": { "spawn-command": {
@@ -2807,11 +2799,6 @@
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="
}, },
"uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
},
"vary": { "vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@@ -1,24 +1,27 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "0.1.4", "version": "0.1.5",
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish", "description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
"main": "server/index.js", "main": "server/index.js",
"scripts": { "scripts": {
"dev": "nodemon server/index.js", "dev": "nodemon server/index.js",
"start": "node server/index.js", "start": "node server/index.js",
"install:all": "npm install" "install:all": "npm install",
"audit": "npm audit --audit-level=moderate",
"audit:fix": "npm audit fix"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2", "axios": "^1.6.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"axios": "^1.6.0", "express": "^4.18.2",
"node-cron": "^3.0.3", "express-rate-limit": "^6.7.0",
"cookie-parser": "^1.4.6" "helmet": "^4.6.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^2.0.22", "concurrently": "^7.6.0",
"concurrently": "^7.6.0" "nodemon": "^3.1.14"
}, },
"keywords": [ "keywords": [
"sabnzbd", "sabnzbd",

View File

@@ -99,7 +99,15 @@ function dismissSplash(startTime) {
setTimeout(() => { setTimeout(() => {
const splash = document.getElementById('splash-screen'); const splash = document.getElementById('splash-screen');
splash.classList.add('fade-out'); splash.classList.add('fade-out');
// Fallback: resolve after transition duration + buffer in case
// transitionend never fires (e.g. display was toggled in same frame)
const TRANSITION_MS = 400;
const fallback = setTimeout(() => {
splash.style.display = 'none';
resolve();
}, TRANSITION_MS + 100);
splash.addEventListener('transitionend', () => { splash.addEventListener('transitionend', () => {
clearTimeout(fallback);
splash.style.display = 'none'; splash.style.display = 'none';
resolve(); resolve();
}, { once: true }); }, { once: true });
@@ -136,6 +144,7 @@ async function handleLogin(e) {
const username = document.getElementById('username').value; const username = document.getElementById('username').value;
const password = document.getElementById('password').value; const password = document.getElementById('password').value;
const rememberMe = document.getElementById('remember-me').checked;
try { try {
const response = await fetch('/api/auth/login', { const response = await fetch('/api/auth/login', {
@@ -143,7 +152,7 @@ async function handleLogin(e) {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password, rememberMe })
}); });
const data = await response.json(); const data = await response.json();
@@ -151,9 +160,13 @@ async function handleLogin(e) {
if (data.success) { if (data.success) {
currentUser = data.user; currentUser = data.user;
isAdmin = !!data.user.isAdmin; isAdmin = !!data.user.isAdmin;
// Fade out login, then show splash while loading data // Fade out login, then show splash while loading data.
// requestAnimationFrame ensures the browser paints the splash at
// opacity:1 before dismissSplash adds fade-out, so the CSS
// transition fires and transitionend is guaranteed.
await fadeOutLogin(); await fadeOutLogin();
showSplash(); showSplash();
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
showDashboard(); showDashboard();
const splashStart = Date.now(); const splashStart = Date.now();
await fetchUserDownloads(true); await fetchUserDownloads(true);
@@ -434,11 +447,30 @@ function createDownloadCard(download) {
infoDiv.appendChild(movie); infoDiv.appendChild(movie);
} }
if (showAll && download.userTag) { if (showAll && download.tagBadges && download.tagBadges.length > 0) {
const userBadge = document.createElement('span'); // In showAll mode: render all tags classified by whether they match an Emby user.
userBadge.className = 'download-user-badge'; // Unmatched (no known Emby user) → amber, leftmost.
userBadge.textContent = download.userTag; // Matched → show Emby display name in accent colour, rightmost.
header.appendChild(userBadge); const unmatched = download.tagBadges.filter(b => !b.matchedUser);
const matched = download.tagBadges.filter(b => b.matchedUser);
for (const b of unmatched) {
const badge = document.createElement('span');
badge.className = 'download-user-badge unmatched';
badge.textContent = b.label;
header.appendChild(badge);
}
for (const b of matched) {
const badge = document.createElement('span');
badge.className = 'download-user-badge';
badge.textContent = b.matchedUser;
header.appendChild(badge);
}
} else if (download.matchedUserTag) {
// Normal (non-showAll) view: show only the current user's matched tag
const matchedBadge = document.createElement('span');
matchedBadge.className = 'download-user-badge';
matchedBadge.textContent = download.matchedUserTag;
header.appendChild(matchedBadge);
} }
const details = document.createElement('div'); const details = document.createElement('div');

BIN
public/favicon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
public/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -4,6 +4,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>sofarr - Your Downloads Dashboard</title> <title>sofarr - Your Downloads Dashboard</title>
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
<meta name="theme-color" content="#1a1a2e">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
@@ -27,6 +31,12 @@
<label for="password">Password:</label> <label for="password">Password:</label>
<input type="password" id="password" name="password" required> <input type="password" id="password" name="password" required>
</div> </div>
<div class="form-group form-group--checkbox">
<label class="checkbox-label">
<input type="checkbox" id="remember-me" name="rememberMe">
<span>Keep me logged in</span>
</label>
</div>
<button type="submit" class="login-btn">Login</button> <button type="submit" class="login-btn">Login</button>
</form> </form>
<div id="login-error" class="error-message" style="display: none;"></div> <div id="login-error" class="error-message" style="display: none;"></div>

View File

@@ -64,6 +64,8 @@
--footer-text: rgba(255, 255, 255, 0.9); --footer-text: rgba(255, 255, 255, 0.9);
--input-bg: #ffffff; --input-bg: #ffffff;
--select-bg: #ffffff; --select-bg: #ffffff;
--unmatched-tag-bg: #fff3e0;
--unmatched-tag-color: #e65100;
} }
[data-theme="dark"] { [data-theme="dark"] {
@@ -100,6 +102,8 @@
--footer-text: rgba(200, 200, 220, 0.8); --footer-text: rgba(200, 200, 220, 0.8);
--input-bg: #2a2a3d; --input-bg: #2a2a3d;
--select-bg: #2a2a3d; --select-bg: #2a2a3d;
--unmatched-tag-bg: #3d2a00;
--unmatched-tag-color: #ffb74d;
} }
[data-theme="mono"] { [data-theme="mono"] {
@@ -136,6 +140,8 @@
--footer-text: rgba(180, 180, 180, 0.7); --footer-text: rgba(180, 180, 180, 0.7);
--input-bg: #252525; --input-bg: #252525;
--select-bg: #252525; --select-bg: #252525;
--unmatched-tag-bg: #2a2a2a;
--unmatched-tag-color: #a0a0a0;
} }
/* ===== Base ===== */ /* ===== Base ===== */
@@ -606,6 +612,32 @@ body {
border-color: var(--accent); border-color: var(--accent);
} }
.form-group--checkbox {
margin-bottom: 20px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 0.9rem;
color: var(--text-secondary);
user-select: none;
}
.checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
flex-shrink: 0;
}
.checkbox-label span {
line-height: 1;
}
.login-btn { .login-btn {
width: 100%; width: 100%;
padding: 10px; padding: 10px;
@@ -734,6 +766,12 @@ body {
white-space: nowrap; white-space: nowrap;
} }
.download-user-badge.unmatched {
background: var(--unmatched-tag-bg);
color: var(--unmatched-tag-color);
margin-left: 0;
}
/* ===== Status Button ===== */ /* ===== Status Button ===== */
.status-btn { .status-btn {
padding: 4px 12px; padding: 4px 12px;

View File

@@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const cors = require('cors');
const path = require('path'); const path = require('path');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
const helmet = require('helmet');
const fs = require('fs'); const fs = require('fs');
require('dotenv').config(); require('dotenv').config();
@@ -59,8 +59,18 @@ const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller'
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
app.use(cors()); app.use(helmet({
app.use(cookieParser()); contentSecurityPolicy: false // SPA uses inline scripts; CSP requires a nonce/hash strategy
}));
const cookieSecret = process.env.COOKIE_SECRET;
if (!cookieSecret && process.env.NODE_ENV === 'production') {
console.error('[Security] COOKIE_SECRET is not set in production — cookies are unsigned and can be tampered with!');
process.exit(1);
} else if (!cookieSecret) {
console.warn('[Security] COOKIE_SECRET is not set — using unsigned cookies (acceptable for development only)');
}
app.use(cookieParser(cookieSecret || undefined));
app.use(express.json()); app.use(express.json());
app.use(express.static(path.join(__dirname, '../public'))); app.use(express.static(path.join(__dirname, '../public')));

View File

@@ -0,0 +1,21 @@
function requireAuth(req, res, next) {
const signed = !!process.env.COOKIE_SECRET;
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
if (!raw || raw === false) {
return res.status(401).json({ error: 'Not authenticated' });
}
let u;
try {
u = JSON.parse(raw);
} catch {
return res.status(401).json({ error: 'Invalid session' });
}
// Schema validation
if (typeof u.id !== 'string' || !u.id) return res.status(401).json({ error: 'Invalid session' });
if (typeof u.name !== 'string' || !u.name) return res.status(401).json({ error: 'Invalid session' });
if (typeof u.isAdmin !== 'boolean') u.isAdmin = !!u.isAdmin;
req.user = u;
next();
}
module.exports = requireAuth;

View File

@@ -1,29 +1,57 @@
const express = require('express'); const express = require('express');
const axios = require('axios'); const axios = require('axios');
const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const router = express.Router(); const router = express.Router();
const EMBY_URL = process.env.EMBY_URL; const EMBY_URL = process.env.EMBY_URL;
const EMBY_API_KEY = process.env.EMBY_API_KEY;
// Server-side token store: userId -> { accessToken }
// Keeps AccessToken off the client; required for logout revocation.
const tokenStore = new Map();
function storeToken(userId, accessToken) {
tokenStore.set(userId, { accessToken });
}
function getToken(userId) {
return tokenStore.get(userId) || null;
}
function clearToken(userId) {
tokenStore.delete(userId);
}
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { success: false, error: 'Too many login attempts, please try again later' }
});
// Authenticate user with Emby // Authenticate user with Emby
router.post('/login', async (req, res) => { router.post('/login', loginLimiter, async (req, res) => {
try { try {
const { username, password } = req.body; const { username, password, rememberMe } = req.body;
console.log(`[Auth] Attempting login for user: ${username}`); console.log(`[Auth] Attempting login for user: ${username}`);
// Authenticate with Emby // Authenticate with Emby using a stable DeviceId derived from the username.
// Using a deterministic DeviceId causes Emby to reuse the existing session
// for this device rather than creating a new one on each login.
const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16);
const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, { const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, {
Username: username, Username: username,
Pw: password Pw: password
}, { }, {
headers: { headers: {
'X-Emby-Authorization': `MediaBrowser Client="MediaDashboard", Device="Browser", DeviceId="dashboard-${Date.now()}", Version="1.0.0"` 'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"`
} }
}); });
const authData = authResponse.data; const authData = authResponse.data;
console.log(`[Auth] Emby auth response:`, JSON.stringify(authData));
// Get user info using the access token // Get user info using the access token
const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, { const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, {
@@ -33,28 +61,31 @@ router.post('/login', async (req, res) => {
}); });
const user = userResponse.data; const user = userResponse.data;
console.log(`[Auth] User info:`, JSON.stringify(user));
console.log(`[Auth] Login successful for user: ${user.Name}`);
// Set authentication cookie
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator); const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
res.cookie('emby_user', JSON.stringify({ console.log(`[Auth] Login successful for user: ${user.Name}, isAdmin: ${isAdmin}`);
id: user.Id,
name: user.Name, // Store token server-side; it is never sent to the client.
isAdmin: isAdmin, storeToken(user.Id, authData.AccessToken);
token: authData.AccessToken
}), { // Set authentication cookie (signed when COOKIE_SECRET is set).
// rememberMe=true → persistent cookie, expires in 30 days
// rememberMe=false → session cookie, expires when browser closes
const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin });
const signed = !!process.env.COOKIE_SECRET;
const cookieOptions = {
httpOnly: true, httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours secure: process.env.NODE_ENV === 'production',
}); sameSite: 'strict',
signed
};
if (rememberMe) {
cookieOptions.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
}
res.cookie('emby_user', cookiePayload, cookieOptions);
res.json({ res.json({
success: true, success: true,
user: { user: { id: user.Id, name: user.Name, isAdmin }
id: user.Id,
name: user.Name,
isAdmin: isAdmin
}
}); });
} catch (error) { } catch (error) {
console.error(`[Auth] Login failed:`, error.message); console.error(`[Auth] Login failed:`, error.message);
@@ -65,33 +96,55 @@ router.post('/login', async (req, res) => {
} }
}); });
function parseSessionCookie(req) {
const signed = !!process.env.COOKIE_SECRET;
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
if (!raw || raw === false) return null; // false = tampered signed cookie
try {
const u = JSON.parse(raw);
// Schema validation: require id (string), name (string), isAdmin (boolean)
if (typeof u.id !== 'string' || !u.id) return null;
if (typeof u.name !== 'string' || !u.name) return null;
if (typeof u.isAdmin !== 'boolean') u.isAdmin = !!u.isAdmin;
return u;
} catch {
return null;
}
}
// Get current authenticated user // Get current authenticated user
router.get('/me', (req, res) => { router.get('/me', (req, res) => {
try { const user = parseSessionCookie(req);
const userCookie = req.cookies.emby_user; if (!user) return res.json({ authenticated: false });
if (!userCookie) {
return res.json({ authenticated: false });
}
const user = JSON.parse(userCookie);
res.json({ res.json({
authenticated: true, authenticated: true,
user: { user: { id: user.id, name: user.name, isAdmin: user.isAdmin }
id: user.id,
name: user.name,
isAdmin: !!user.isAdmin
}
}); });
} catch (error) {
console.error(`[Auth] Error getting current user:`, error.message);
res.json({ authenticated: false });
}
}); });
// Logout // Logout
router.post('/logout', (req, res) => { router.post('/logout', async (req, res) => {
res.clearCookie('emby_user'); const user = parseSessionCookie(req);
if (user) {
const stored = getToken(user.id);
if (stored) {
try {
await axios.post(`${EMBY_URL}/Sessions/Logout`, {}, {
headers: { 'X-MediaBrowser-Token': stored.accessToken }
});
console.log(`[Auth] Revoked Emby token for user: ${user.name}`);
} catch (err) {
console.warn(`[Auth] Failed to revoke Emby token for ${user.name}:`, err.message);
}
clearToken(user.id);
}
}
res.clearCookie('emby_user', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
signed: !!process.env.COOKIE_SECRET
});
res.json({ success: true }); res.json({ success: true });
}); });

View File

@@ -1,14 +1,14 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const axios = require('axios'); const axios = require('axios');
const { mapTorrentToDownload } = require('../utils/qbittorrent'); const { mapTorrentToDownload } = require('../utils/qbittorrent');
const cache = require('../utils/cache'); const cache = require('../utils/cache');
const { pollAllServices, getLastPollTimings, POLLING_ENABLED } = require('../utils/poller'); const { pollAllServices, getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
const { getSonarrInstances, getRadarrInstances } = require('../utils/config'); const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
const sanitizeError = require('../utils/sanitizeError');
const EMBY_URL = process.env.EMBY_URL;
const EMBY_API_KEY = process.env.EMBY_API_KEY;
// Helper function to extract poster/cover art URL from a movie or series object // Helper function to extract poster/cover art URL from a movie or series object
function getCoverArt(item) { function getCoverArt(item) {
@@ -20,26 +20,28 @@ function getCoverArt(item) {
return fanart ? (fanart.remoteUrl || fanart.url || null) : null; return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
} }
// Helper function to extract user tag from series/movie // Return all resolved tag labels for a series/movie.
// For Radarr: tags is array of IDs, tagMap is id -> label mapping // For Radarr: tags is array of IDs, tagMap is id -> label mapping.
// For Sonarr: tags is array of objects with label property // For Sonarr: tags are objects with a label property.
function extractUserTag(tags, tagMap) { function extractAllTags(tags, tagMap) {
if (!tags || tags.length === 0) return null; if (!tags || tags.length === 0) return [];
// If tagMap provided (Radarr), look up label by ID
if (tagMap) { if (tagMap) {
for (const tagId of tags) { return tags.map(id => tagMap.get(id)).filter(Boolean);
const label = tagMap.get(tagId); }
if (label) return label; return tags.map(t => t && t.label).filter(Boolean);
}
// Return the tag label that matches the current username, or null.
function extractUserTag(tags, tagMap, username) {
const allLabels = extractAllTags(tags, tagMap);
if (!allLabels.length) return null;
if (username) {
const match = allLabels.find(label => tagMatchesUser(label, username));
if (match) return match;
} }
return null; return null;
} }
// Sonarr style - tags are objects with label
const userTag = tags.find(tag => tag && tag.label);
return userTag ? userTag.label : null;
}
// Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim // Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim
function sanitizeTagLabel(input) { function sanitizeTagLabel(input) {
if (!input) return ''; if (!input) return '';
@@ -92,6 +94,41 @@ function getRadarrLink(movie) {
return `${movie._instanceUrl}/movie/${movie.titleSlug}`; return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
} }
// Fetch all Emby users and return a Map<lowerName -> displayName> (and sanitized variants).
// Result is cached for 60s to avoid hammering Emby on every dashboard poll.
async function getEmbyUsers() {
const cached = cache.get('emby:users');
if (cached) return cached;
try {
const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
});
// Build map: both raw lowercase and sanitized form -> display name
const map = new Map();
for (const u of response.data) {
const name = u.Name || '';
map.set(name.toLowerCase(), name);
map.set(sanitizeTagLabel(name), name);
}
cache.set('emby:users', map, 60000);
return map;
} catch (err) {
console.error('[Dashboard] Failed to fetch Emby users:', err.message);
return new Map();
}
}
// Classify each tag label: matched to a known Emby user, or unmatched.
// Returns array of { label, matchedUser: string|null }
function buildTagBadges(allTags, embyUserMap) {
return allTags.map(label => {
const lower = label.toLowerCase();
const sanitized = sanitizeTagLabel(label);
const displayName = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
return { label, matchedUser: displayName };
});
}
// Track active dashboard clients: Map<username, { refreshRateMs, lastSeen }> // Track active dashboard clients: Map<username, { refreshRateMs, lastSeen }>
const activeClients = new Map(); const activeClients = new Map();
const CLIENT_STALE_MS = 30000; // consider client gone after 30s of no requests const CLIENT_STALE_MS = 30000; // consider client gone after 30s of no requests
@@ -106,15 +143,9 @@ function getActiveClients() {
} }
// Get user downloads for authenticated user // Get user downloads for authenticated user
router.get('/user-downloads', async (req, res) => { router.get('/user-downloads', requireAuth, async (req, res) => {
try { try {
// Get authenticated user from cookie const user = req.user;
const userCookie = req.cookies.emby_user;
if (!userCookie) {
return res.status(401).json({ error: 'Not authenticated' });
}
const user = JSON.parse(userCookie);
const username = user.name.toLowerCase(); const username = user.name.toLowerCase();
const usernameSanitized = sanitizeTagLabel(user.name); const usernameSanitized = sanitizeTagLabel(user.name);
const isAdmin = !!user.isAdmin; const isAdmin = !!user.isAdmin;
@@ -179,6 +210,9 @@ router.get('/user-downloads', async (req, res) => {
const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label])); const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label])); const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label]));
// When showing all downloads, fetch full Emby user list to classify tags
const embyUserMap = showAll ? await getEmbyUsers() : new Map();
console.log(`[Dashboard] Cache data - Series: ${seriesMap.size}, Movies: ${moviesMap.size}, qBit: ${qbittorrentTorrents.length}`); console.log(`[Dashboard] Cache data - Series: ${seriesMap.size}, Movies: ${moviesMap.size}, qBit: ${qbittorrentTorrents.length}`);
// Match SABnzbd downloads to Sonarr/Radarr activity // Match SABnzbd downloads to Sonarr/Radarr activity
@@ -224,8 +258,10 @@ router.get('/user-downloads', async (req, res) => {
if (sonarrMatch && sonarrMatch.seriesId) { if (sonarrMatch && sonarrMatch.seriesId) {
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series; const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) { if (series) {
const userTag = extractUserTag(series.tags, sonarrTagMap); const allTags = extractAllTags(series.tags, sonarrTagMap);
if (userTag && (showAll || tagMatchesUser(userTag, username))) { const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (showAll ? hasAnyTag : !!matchedUserTag) {
const dlObj = { const dlObj = {
type: 'series', type: 'series',
title: nzbName, title: nzbName,
@@ -239,7 +275,9 @@ router.get('/user-downloads', async (req, res) => {
eta: slot.timeleft, eta: slot.timeleft,
seriesName: series.title, seriesName: series.title,
episodeInfo: sonarrMatch, episodeInfo: sonarrMatch,
userTag: userTag allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
}; };
const issues = getImportIssues(sonarrMatch); const issues = getImportIssues(sonarrMatch);
if (issues) dlObj.importIssues = issues; if (issues) dlObj.importIssues = issues;
@@ -262,8 +300,10 @@ router.get('/user-downloads', async (req, res) => {
if (radarrMatch && radarrMatch.movieId) { if (radarrMatch && radarrMatch.movieId) {
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie; const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
if (movie) { if (movie) {
const userTag = extractUserTag(movie.tags, radarrTagMap); const allTags = extractAllTags(movie.tags, radarrTagMap);
if (userTag && (showAll || tagMatchesUser(userTag, username))) { const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (showAll ? hasAnyTag : !!matchedUserTag) {
const dlObj = { const dlObj = {
type: 'movie', type: 'movie',
title: nzbName, title: nzbName,
@@ -277,7 +317,9 @@ router.get('/user-downloads', async (req, res) => {
eta: slot.timeleft, eta: slot.timeleft,
movieName: movie.title, movieName: movie.title,
movieInfo: radarrMatch, movieInfo: radarrMatch,
userTag: userTag allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
}; };
const issues = getImportIssues(radarrMatch); const issues = getImportIssues(radarrMatch);
if (issues) dlObj.importIssues = issues; if (issues) dlObj.importIssues = issues;
@@ -317,8 +359,10 @@ router.get('/user-downloads', async (req, res) => {
if (sonarrMatch && sonarrMatch.seriesId) { if (sonarrMatch && sonarrMatch.seriesId) {
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series; const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) { if (series) {
const userTag = extractUserTag(series.tags, sonarrTagMap); const allTags = extractAllTags(series.tags, sonarrTagMap);
if (userTag && (showAll || tagMatchesUser(userTag, username))) { const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (showAll ? hasAnyTag : !!matchedUserTag) {
const dlObj = { const dlObj = {
type: 'series', type: 'series',
title: nzbName, title: nzbName,
@@ -328,7 +372,9 @@ router.get('/user-downloads', async (req, res) => {
completedAt: slot.completed_time, completedAt: slot.completed_time,
seriesName: series.title, seriesName: series.title,
episodeInfo: sonarrMatch, episodeInfo: sonarrMatch,
userTag: userTag allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
}; };
if (isAdmin) { if (isAdmin) {
dlObj.downloadPath = slot.storage || null; dlObj.downloadPath = slot.storage || null;
@@ -349,8 +395,10 @@ router.get('/user-downloads', async (req, res) => {
if (radarrMatch && radarrMatch.movieId) { if (radarrMatch && radarrMatch.movieId) {
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie; const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
if (movie) { if (movie) {
const userTag = extractUserTag(movie.tags, radarrTagMap); const allTags = extractAllTags(movie.tags, radarrTagMap);
if (userTag && (showAll || tagMatchesUser(userTag, username))) { const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (showAll ? hasAnyTag : !!matchedUserTag) {
const dlObj = { const dlObj = {
type: 'movie', type: 'movie',
title: nzbName, title: nzbName,
@@ -360,7 +408,9 @@ router.get('/user-downloads', async (req, res) => {
completedAt: slot.completed_time, completedAt: slot.completed_time,
movieName: movie.title, movieName: movie.title,
movieInfo: radarrMatch, movieInfo: radarrMatch,
userTag: userTag allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
}; };
if (isAdmin) { if (isAdmin) {
dlObj.downloadPath = slot.storage || null; dlObj.downloadPath = slot.storage || null;
@@ -388,12 +438,10 @@ router.get('/user-downloads', async (req, res) => {
// Show movies/series tagged for this user (from embedded objects in queue/history) // Show movies/series tagged for this user (from embedded objects in queue/history)
const userMovies = Array.from(moviesMap.values()).filter(m => { const userMovies = Array.from(moviesMap.values()).filter(m => {
const tag = extractUserTag(m.tags, radarrTagMap); return !!extractUserTag(m.tags, radarrTagMap, username);
return tag && tagMatchesUser(tag, username);
}); });
const userSeries = Array.from(seriesMap.values()).filter(s => { const userSeries = Array.from(seriesMap.values()).filter(s => {
const tag = extractUserTag(s.tags, sonarrTagMap); return !!extractUserTag(s.tags, sonarrTagMap, username);
return tag && tagMatchesUser(tag, username);
}); });
console.log(`[Dashboard] Movies tagged for ${username}:`, userMovies.map(m => m.title)); console.log(`[Dashboard] Movies tagged for ${username}:`, userMovies.map(m => m.title));
console.log(`[Dashboard] Series tagged for ${username}:`, userSeries.map(s => s.title)); console.log(`[Dashboard] Series tagged for ${username}:`, userSeries.map(s => s.title));
@@ -417,15 +465,19 @@ router.get('/user-downloads', async (req, res) => {
if (sonarrMatch && sonarrMatch.seriesId) { if (sonarrMatch && sonarrMatch.seriesId) {
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series; const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) { if (series) {
const userTag = extractUserTag(series.tags, sonarrTagMap); const allTags = extractAllTags(series.tags, sonarrTagMap);
if (userTag && (showAll || tagMatchesUser(userTag, username))) { const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (showAll ? hasAnyTag : !!matchedUserTag) {
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr series "${series.title}"`); console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr series "${series.title}"`);
const download = mapTorrentToDownload(torrent); const download = mapTorrentToDownload(torrent);
download.type = 'series'; download.type = 'series';
download.coverArt = getCoverArt(series); download.coverArt = getCoverArt(series);
download.seriesName = series.title; download.seriesName = series.title;
download.episodeInfo = sonarrMatch; download.episodeInfo = sonarrMatch;
download.userTag = userTag; download.allTags = allTags;
download.matchedUserTag = matchedUserTag || null;
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
const sonarrIssues = getImportIssues(sonarrMatch); const sonarrIssues = getImportIssues(sonarrMatch);
if (sonarrIssues) download.importIssues = sonarrIssues; if (sonarrIssues) download.importIssues = sonarrIssues;
if (isAdmin) { if (isAdmin) {
@@ -448,15 +500,19 @@ router.get('/user-downloads', async (req, res) => {
if (radarrMatch && radarrMatch.movieId) { if (radarrMatch && radarrMatch.movieId) {
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie; const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
if (movie) { if (movie) {
const userTag = extractUserTag(movie.tags, radarrTagMap); const allTags = extractAllTags(movie.tags, radarrTagMap);
if (userTag && (showAll || tagMatchesUser(userTag, username))) { const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (showAll ? hasAnyTag : !!matchedUserTag) {
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr movie "${movie.title}"`); console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr movie "${movie.title}"`);
const download = mapTorrentToDownload(torrent); const download = mapTorrentToDownload(torrent);
download.type = 'movie'; download.type = 'movie';
download.coverArt = getCoverArt(movie); download.coverArt = getCoverArt(movie);
download.movieName = movie.title; download.movieName = movie.title;
download.movieInfo = radarrMatch; download.movieInfo = radarrMatch;
download.userTag = userTag; download.allTags = allTags;
download.matchedUserTag = matchedUserTag || null;
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
const radarrIssues = getImportIssues(radarrMatch); const radarrIssues = getImportIssues(radarrMatch);
if (radarrIssues) download.importIssues = radarrIssues; if (radarrIssues) download.importIssues = radarrIssues;
if (isAdmin) { if (isAdmin) {
@@ -479,15 +535,19 @@ router.get('/user-downloads', async (req, res) => {
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) { if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series; const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
if (series) { if (series) {
const userTag = extractUserTag(series.tags, sonarrTagMap); const allTags = extractAllTags(series.tags, sonarrTagMap);
if (userTag && (showAll || tagMatchesUser(userTag, username))) { const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (showAll ? hasAnyTag : !!matchedUserTag) {
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr history "${series.title}"`); console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr history "${series.title}"`);
const download = mapTorrentToDownload(torrent); const download = mapTorrentToDownload(torrent);
download.type = 'series'; download.type = 'series';
download.coverArt = getCoverArt(series); download.coverArt = getCoverArt(series);
download.seriesName = series.title; download.seriesName = series.title;
download.episodeInfo = sonarrHistoryMatch; download.episodeInfo = sonarrHistoryMatch;
download.userTag = userTag; download.allTags = allTags;
download.matchedUserTag = matchedUserTag || null;
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
if (isAdmin) { if (isAdmin) {
download.downloadPath = download.savePath || null; download.downloadPath = download.savePath || null;
download.targetPath = series.path || null; download.targetPath = series.path || null;
@@ -508,15 +568,19 @@ router.get('/user-downloads', async (req, res) => {
if (radarrHistoryMatch && radarrHistoryMatch.movieId) { if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie; const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
if (movie) { if (movie) {
const userTag = extractUserTag(movie.tags, radarrTagMap); const allTags = extractAllTags(movie.tags, radarrTagMap);
if (userTag && (showAll || tagMatchesUser(userTag, username))) { const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (showAll ? hasAnyTag : !!matchedUserTag) {
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr history "${movie.title}"`); console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr history "${movie.title}"`);
const download = mapTorrentToDownload(torrent); const download = mapTorrentToDownload(torrent);
download.type = 'movie'; download.type = 'movie';
download.coverArt = getCoverArt(movie); download.coverArt = getCoverArt(movie);
download.movieName = movie.title; download.movieName = movie.title;
download.movieInfo = radarrHistoryMatch; download.movieInfo = radarrHistoryMatch;
download.userTag = userTag; download.allTags = allTags;
download.matchedUserTag = matchedUserTag || null;
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
if (isAdmin) { if (isAdmin) {
download.downloadPath = download.savePath || null; download.downloadPath = download.savePath || null;
download.targetPath = movie.path || null; download.targetPath = movie.path || null;
@@ -547,19 +611,19 @@ router.get('/user-downloads', async (req, res) => {
} catch (error) { } catch (error) {
console.error(`[Dashboard] Error fetching user downloads:`, error.message); console.error(`[Dashboard] Error fetching user downloads:`, error.message);
console.error(`[Dashboard] Full error:`, error); console.error(`[Dashboard] Full error:`, error);
res.status(500).json({ error: 'Failed to fetch user downloads', details: error.message }); res.status(500).json({ error: 'Failed to fetch user downloads', details: sanitizeError(error) });
} }
}); });
// Get all users with their download counts // Get all users with their download counts
router.get('/user-summary', async (req, res) => { router.get('/user-summary', requireAuth, async (req, res) => {
try { try {
const sonarrInstances = getSonarrInstances(); const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances(); const radarrInstances = getRadarrInstances();
// Get all Emby users // Get all Emby users
const usersResponse = await axios.get(`${EMBY_URL}/Users`, { const usersResponse = await axios.get(`${process.env.EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY } headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
}); });
// Get all series, movies, and tags from all instances // Get all series, movies, and tags from all instances
@@ -603,40 +667,32 @@ router.get('/user-summary', async (req, res) => {
// Process series tags // Process series tags
allSeries.forEach(series => { allSeries.forEach(series => {
const userTag = extractUserTag(series.tags, sonarrTagMap); const tags = extractAllTags(series.tags, sonarrTagMap);
if (userTag) { tags.forEach(userTag => {
const username = userTag.toLowerCase(); const uname = userTag.toLowerCase();
if (userDownloads[username]) { if (userDownloads[uname]) userDownloads[uname].seriesCount++;
userDownloads[username].seriesCount++; });
}
}
}); });
// Process movie tags // Process movie tags
allMovies.forEach(movie => { allMovies.forEach(movie => {
const userTag = extractUserTag(movie.tags, radarrTagMap); const tags = extractAllTags(movie.tags, radarrTagMap);
if (userTag) { tags.forEach(userTag => {
const username = userTag.toLowerCase(); const uname = userTag.toLowerCase();
if (userDownloads[username]) { if (userDownloads[uname]) userDownloads[uname].movieCount++;
userDownloads[username].movieCount++; });
}
}
}); });
res.json(Object.values(userDownloads)); res.json(Object.values(userDownloads));
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch user summary', details: error.message }); res.status(500).json({ error: 'Failed to fetch user summary', details: sanitizeError(error) });
} }
}); });
// Admin-only status page with cache stats // Admin-only status page with cache stats
router.get('/status', (req, res) => { router.get('/status', requireAuth, (req, res) => {
try { try {
const userCookie = req.cookies.emby_user; const user = req.user;
if (!userCookie) {
return res.status(401).json({ error: 'Not authenticated' });
}
const user = JSON.parse(userCookie);
if (!user.isAdmin) { if (!user.isAdmin) {
return res.status(403).json({ error: 'Admin access required' }); return res.status(403).json({ error: 'Admin access required' });
} }

View File

@@ -1,51 +1,52 @@
const express = require('express'); const express = require('express');
const axios = require('axios'); const axios = require('axios');
const router = express.Router(); const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const EMBY_URL = process.env.EMBY_URL; router.use(requireAuth);
const EMBY_API_KEY = process.env.EMBY_API_KEY;
// Get active sessions // Get active sessions
router.get('/sessions', async (req, res) => { router.get('/sessions', async (req, res) => {
try { try {
const response = await axios.get(`${EMBY_URL}/Sessions`, { const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY } headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch Emby sessions', details: error.message }); res.status(500).json({ error: 'Failed to fetch Emby sessions', details: sanitizeError(error) });
} }
}); });
// Get user by ID // Get user by ID
router.get('/users/:id', async (req, res) => { router.get('/users/:id', async (req, res) => {
try { try {
const response = await axios.get(`${EMBY_URL}/Users/${req.params.id}`, { const response = await axios.get(`${process.env.EMBY_URL}/Users/${req.params.id}`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY } headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch user details', details: error.message }); res.status(500).json({ error: 'Failed to fetch user details', details: sanitizeError(error) });
} }
}); });
// Get all users // Get all users
router.get('/users', async (req, res) => { router.get('/users', async (req, res) => {
try { try {
const response = await axios.get(`${EMBY_URL}/Users`, { const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY } headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch users', details: error.message }); res.status(500).json({ error: 'Failed to fetch users', details: sanitizeError(error) });
} }
}); });
// Get current user by session ID // Get current user by session ID
router.get('/session/:sessionId/user', async (req, res) => { router.get('/session/:sessionId/user', async (req, res) => {
try { try {
const response = await axios.get(`${EMBY_URL}/Sessions`, { const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY } headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
}); });
const session = response.data.find(s => s.Id === req.params.sessionId); const session = response.data.find(s => s.Id === req.params.sessionId);
@@ -53,13 +54,13 @@ router.get('/session/:sessionId/user', async (req, res) => {
return res.status(404).json({ error: 'Session not found' }); return res.status(404).json({ error: 'Session not found' });
} }
const userResponse = await axios.get(`${EMBY_URL}/Users/${session.UserId}`, { const userResponse = await axios.get(`${process.env.EMBY_URL}/Users/${session.UserId}`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY } headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
}); });
res.json(userResponse.data); res.json(userResponse.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch user from session', details: error.message }); res.status(500).json({ error: 'Failed to fetch user from session', details: sanitizeError(error) });
} }
}); });

View File

@@ -1,56 +1,57 @@
const express = require('express'); const express = require('express');
const axios = require('axios'); const axios = require('axios');
const router = express.Router(); const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const RADARR_URL = process.env.RADARR_URL; router.use(requireAuth);
const RADARR_API_KEY = process.env.RADARR_API_KEY;
// Get queue // Get queue
router.get('/queue', async (req, res) => { router.get('/queue', async (req, res) => {
try { try {
const response = await axios.get(`${RADARR_URL}/api/v3/queue`, { const response = await axios.get(`${process.env.RADARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': RADARR_API_KEY } headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr queue', details: error.message }); res.status(500).json({ error: 'Failed to fetch Radarr queue', details: sanitizeError(error) });
} }
}); });
// Get history // Get history
router.get('/history', async (req, res) => { router.get('/history', async (req, res) => {
try { try {
const response = await axios.get(`${RADARR_URL}/api/v3/history`, { const response = await axios.get(`${process.env.RADARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': RADARR_API_KEY }, headers: { 'X-Api-Key': process.env.RADARR_API_KEY },
params: { pageSize: req.query.pageSize || 50 } params: { pageSize: req.query.pageSize || 50 }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch Radarr history', details: error.message }); res.status(500).json({ error: 'Failed to fetch Radarr history', details: sanitizeError(error) });
} }
}); });
// Get movie details // Get movie details
router.get('/movies/:id', async (req, res) => { router.get('/movies/:id', async (req, res) => {
try { try {
const response = await axios.get(`${RADARR_URL}/api/v3/movie/${req.params.id}`, { const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie/${req.params.id}`, {
headers: { 'X-Api-Key': RADARR_API_KEY } headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch movie details', details: error.message }); res.status(500).json({ error: 'Failed to fetch movie details', details: sanitizeError(error) });
} }
}); });
// Get all movies with tags // Get all movies with tags
router.get('/movies', async (req, res) => { router.get('/movies', async (req, res) => {
try { try {
const response = await axios.get(`${RADARR_URL}/api/v3/movie`, { const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie`, {
headers: { 'X-Api-Key': RADARR_API_KEY } headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch movies', details: error.message }); res.status(500).json({ error: 'Failed to fetch movies', details: sanitizeError(error) });
} }
}); });

View File

@@ -1,40 +1,41 @@
const express = require('express'); const express = require('express');
const axios = require('axios'); const axios = require('axios');
const router = express.Router(); const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const SABNZBD_URL = process.env.SABNZBD_URL; router.use(requireAuth);
const SABNZBD_API_KEY = process.env.SABNZBD_API_KEY;
// Get current queue // Get current queue
router.get('/queue', async (req, res) => { router.get('/queue', async (req, res) => {
try { try {
const response = await axios.get(`${SABNZBD_URL}/api`, { const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
params: { params: {
mode: 'queue', mode: 'queue',
apikey: SABNZBD_API_KEY, apikey: process.env.SABNZBD_API_KEY,
output: 'json' output: 'json'
} }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: error.message }); res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: sanitizeError(error) });
} }
}); });
// Get history // Get history
router.get('/history', async (req, res) => { router.get('/history', async (req, res) => {
try { try {
const response = await axios.get(`${SABNZBD_URL}/api`, { const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
params: { params: {
mode: 'history', mode: 'history',
apikey: SABNZBD_API_KEY, apikey: process.env.SABNZBD_API_KEY,
output: 'json', output: 'json',
limit: req.query.limit || 50 limit: req.query.limit || 50
} }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: error.message }); res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: sanitizeError(error) });
} }
}); });

View File

@@ -1,56 +1,57 @@
const express = require('express'); const express = require('express');
const axios = require('axios'); const axios = require('axios');
const router = express.Router(); const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const SONARR_URL = process.env.SONARR_URL; router.use(requireAuth);
const SONARR_API_KEY = process.env.SONARR_API_KEY;
// Get queue // Get queue
router.get('/queue', async (req, res) => { router.get('/queue', async (req, res) => {
try { try {
const response = await axios.get(`${SONARR_URL}/api/v3/queue`, { const response = await axios.get(`${process.env.SONARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': SONARR_API_KEY } headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: error.message }); res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: sanitizeError(error) });
} }
}); });
// Get history // Get history
router.get('/history', async (req, res) => { router.get('/history', async (req, res) => {
try { try {
const response = await axios.get(`${SONARR_URL}/api/v3/history`, { const response = await axios.get(`${process.env.SONARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': SONARR_API_KEY }, headers: { 'X-Api-Key': process.env.SONARR_API_KEY },
params: { pageSize: req.query.pageSize || 50 } params: { pageSize: req.query.pageSize || 50 }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch Sonarr history', details: error.message }); res.status(500).json({ error: 'Failed to fetch Sonarr history', details: sanitizeError(error) });
} }
}); });
// Get series details // Get series details
router.get('/series/:id', async (req, res) => { router.get('/series/:id', async (req, res) => {
try { try {
const response = await axios.get(`${SONARR_URL}/api/v3/series/${req.params.id}`, { const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series/${req.params.id}`, {
headers: { 'X-Api-Key': SONARR_API_KEY } headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch series details', details: error.message }); res.status(500).json({ error: 'Failed to fetch series details', details: sanitizeError(error) });
} }
}); });
// Get all series with tags // Get all series with tags
router.get('/series', async (req, res) => { router.get('/series', async (req, res) => {
try { try {
const response = await axios.get(`${SONARR_URL}/api/v3/series`, { const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series`, {
headers: { 'X-Api-Key': SONARR_API_KEY } headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
}); });
res.json(response.data); res.json(response.data);
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch series', details: error.message }); res.status(500).json({ error: 'Failed to fetch series', details: sanitizeError(error) });
} }
}); });

View File

@@ -0,0 +1,15 @@
const API_KEY_PATTERN = /([?&]apikey=)[^&\s]*/gi;
const TOKEN_PATTERN = /([?&]token=)[^&\s]*/gi;
const HEADER_PATTERN = /x-(?:api-key|mediabrowser-token|emby-authorization):[^\s,]*/gi;
function sanitizeError(err) {
let msg = err.message || String(err);
// Redact API keys in URLs (SABnzbd passes apikey as query param)
msg = msg.replace(API_KEY_PATTERN, '$1[REDACTED]');
msg = msg.replace(TOKEN_PATTERN, '$1[REDACTED]');
// Redact auth header values if they appear in the message
msg = msg.replace(HEADER_PATTERN, (m) => m.split(':')[0] + ':[REDACTED]');
return msg;
}
module.exports = sanitizeError;