Compare commits

...

23 Commits

Author SHA1 Message Date
gronod 8eb49f64b6 Merge develop into main for v0.1.5
Build and Push Docker Image / build (push) Successful in 24s
CI / npm audit (push) Successful in 43s
Create Release / release (push) Successful in 15s
2026-05-16 17:18:11 +01:00
gronod 6b8c215497 chore: bump version to 0.1.5
Build and Push Docker Image / build (push) Successful in 34s
CI / npm audit (push) Successful in 40s
2026-05-16 17:18:05 +01:00
gronod 11749a428c fix: splash screen hangs after login, never dismisses
Build and Push Docker Image / build (push) Successful in 28s
CI / npm audit (push) Successful in 45s
Root cause: showSplash() sets display:flex + opacity:1 synchronously,
then dismissSplash() immediately adds the fade-out class (opacity:0).
The browser batches these in the same paint frame so the CSS transition
from opacity:1 -> 0 never starts, and transitionend never fires,
leaving the Promise unresolved and the splash stuck.

Two-part fix:
1. handleLogin: await two requestAnimationFrames between showSplash()
   and dismissSplash() so the browser paints opacity:1 first, ensuring
   the CSS opacity transition actually runs.
2. dismissSplash: add a 500ms fallback setTimeout that hides the splash
   and resolves the Promise even if transitionend is never fired (acts
   as a safety net for any future edge cases).
2026-05-16 17:16:31 +01:00
gronod e83afde5ef feat: add 'Keep me logged in' checkbox to login form
Build and Push Docker Image / build (push) Successful in 26s
CI / npm audit (push) Has been cancelled
- index.html: checkbox between password field and login button
- app.js: reads #remember-me and passes rememberMe in POST body
- auth.js: rememberMe=true sets 30-day maxAge; false = session cookie
  (expires when browser closes)
- style.css: .form-group--checkbox and .checkbox-label styles
2026-05-16 17:15:28 +01:00
gronod 031877e6a0 fix(ci): upgrade nodemon to ^3 to resolve semver ReDoS vulnerability
Build and Push Docker Image / build (push) Successful in 32s
CI / npm audit (push) Successful in 49s
nodemon@2 depends on simple-update-notifier which depends on a
vulnerable range of semver (7.0.0-7.5.1, GHSA-c2qf-rxjj-qqgw).
Upgrading to nodemon@3 pulls in a clean dependency tree.
npm audit now reports 0 vulnerabilities.
2026-05-16 17:11:24 +01:00
gronod 663826e295 chore: add COOKIE_SECRET to .env, .env.example, .env.sample
Build and Push Docker Image / build (push) Successful in 41s
CI / npm audit (push) Failing after 43s
Generated a 64-char hex secret (openssl rand -hex 32 equivalent) and
added it to .env. Updated .env.example and .env.sample with the new
required variable and a generation hint. This is the production secret
for HMAC-signing the emby_user session cookie.
2026-05-16 17:07:43 +01:00
gronod 14de5e4644 fix(security #17): add npm audit to CI pipeline and package scripts
Build and Push Docker Image / build (push) Successful in 32s
CI / npm audit (push) Failing after 2m20s
Added .gitea/workflows/ci.yml which runs 'npm audit --audit-level=moderate'
on every push and PR. Fails the build on any moderate or higher severity
finding.

Also added 'npm run audit' and 'npm run audit:fix' convenience scripts
to package.json for local use.
2026-05-16 16:27:33 +01:00
gronod 44cff5bf41 fix(security #15): read API keys from process.env at request time
Module-level const assignments (SONARR_API_KEY, RADARR_API_KEY,
SABNZBD_API_KEY, EMBY_URL, EMBY_API_KEY) captured values at startup
and would not pick up rotated credentials without a restart.

Replaced all module-level captures in emby.js, sabnzbd.js, sonarr.js,
radarr.js, and dashboard.js with inline process.env reads at each
call site. A process restart is still needed for dotenv-loaded values
but environment-injected vars (Docker, Kubernetes) are re-read live.
2026-05-16 16:26:53 +01:00
gronod bdfb042527 fix(security #13,#14): revoke Emby token on logout; stable DeviceId prevents unbounded sessions
#13 Logout doesn't revoke Emby token:
  - Added in-memory tokenStore (userId -> { accessToken })
  - AccessToken stored server-side after successful login; never sent
    to client
  - POST /logout calls Emby POST /Sessions/Logout with the stored
    token before clearing it; failure is warned but does not block
    the local cookie clear

#14 Unbounded Emby session creation per login:
  - DeviceId in the Emby auth request is now a stable SHA-256 hash
    of the lowercase username (sofarr-<16 hex chars>)
  - Emby treats the same DeviceId as the same device and reuses the
    existing session slot instead of creating a new one each login
2026-05-16 16:25:05 +01:00
gronod b608fa0337 fix(security #12): add helmet security response headers
Adds X-DNS-Prefetch-Control, X-Frame-Options, X-Content-Type-Options,
Referrer-Policy, X-XSS-Protection, HSTS (in prod) and others.
CSP disabled for now as the SPA uses inline scripts/styles; a
nonce/hash-based policy is a future hardening step.
2026-05-16 16:23:47 +01:00
gronod 1f41114482 fix(security #11): remove unused node-cron dependency
node-cron was listed in dependencies but never imported anywhere in
the codebase. Removed via npm uninstall.
2026-05-16 16:22:36 +01:00
gronod 8fa20c6990 fix(security #10): sanitize error details to prevent API key leakage
Added server/utils/sanitizeError.js which redacts:
- ?apikey= query parameters (SABnzbd passes key in URL)
- ?token= query parameters
- X-Api-Key / X-MediaBrowser-Token / X-Emby-Authorization header
  values if they appear in the error message string

Applied to all catch blocks in emby.js, sabnzbd.js, sonarr.js,
radarr.js, and dashboard.js. Internal error.message still logged
server-side (unredacted) for debugging.
2026-05-16 16:22:11 +01:00
gronod d8584d0511 fix(security #7,#8,#9): signed cookies, isAdmin tamper-proof, schema validation
#7 isAdmin trusted from unsigned cookie:
  - isAdmin is derived server-side from Emby Policy at login time
  - Cookie is now signed (HMAC) when COOKIE_SECRET env var is set;
    Express rejects tampered signatures (signedCookies returns false)
  - dashboard.js /user-downloads and /status now use requireAuth
    middleware (req.user) instead of re-parsing cookie directly

#8 cookie-parser used without signing secret:
  - cookieParser(COOKIE_SECRET) in index.js when env var is set
  - Hard-fails at startup in production if COOKIE_SECRET unset
  - Warns in development

#9 Cookie JSON parsed without schema validation:
  - parseSessionCookie() in auth.js and requireAuth.js both validate:
    id (non-empty string), name (non-empty string), isAdmin (boolean)
  - Invalid/tampered cookies return null / 401 respectively
2026-05-16 16:20:37 +01:00
gronod 1eadb30481 fix(security #6): add rate limiting to POST /api/auth/login
Uses express-rate-limit@6 (pinned for Node 12 dev compat; Node 18
in prod container is unaffected). Limits each IP to 10 attempts per
15-minute window. Returns 429 with a safe error message on breach.
2026-05-16 16:18:34 +01:00
gronod 8f96a5f296 fix(security #5): remove plaintext logging of Emby auth response and user object
The full authResponse.data (containing AccessToken) and user object
were being logged via console.log → written to server.log on disk.
Replaced with a single safe log line showing only name and isAdmin.
2026-05-16 16:17:43 +01:00
gronod 6675e5dcfe docs: update architecture docs and diagrams for recent changes
Build and Push Docker Image / build (push) Successful in 24s
ARCHITECTURE.md:
- Directory structure: add middleware/requireAuth.js and favicon assets
- §4.1: remove CORS from middleware list
- §4.2: all proxy routes now auth-required via requireAuth; add
  middleware description
- §6: cookie payload corrected (no token); document secure+sameSite
- §7: add emby:users cache key (60s TTL)
- §8: Download Object table: userTag → allTags/matchedUserTag/tagBadges
- §9 POST /login: document cookie security attributes
- §10: add Tag Badge Rendering section; remove hardcoded line count

Diagrams:
- class-server.puml: add requireAuth middleware module; update
  dashboard.js methods (extractAllTags, extractUserTag w/ username,
  buildTagBadges, getEmbyUsers); add TagBadge value class; add auth
  relationships for all proxy routes
- class-data.puml: Download Object userTag → allTags/matchedUserTag/
  tagBadges; add TagBadge class; remove token from Session Cookie
- seq-auth.puml: cookie payload no longer contains token; add
  secure/sameSite note
- component.puml: remove CORS component; add requireAuth; consolidate
  Emby connection to show tag badge + user-summary usage
- activity-matching.puml: update to extractAllTags/extractUserTag
  (with username); showAll uses hasAnyTag; tagBadges built from
  embyUserMap; add Emby user fetch step; update legend
- seq-dashboard.puml: add emby:users cache lookup / Emby fetch for
  showAll; update matching groups to show tag classification; add
  tag badge rendering note on renderDownloads()
2026-05-16 15:41:23 +01:00
gronod 54647ab7cf feat: add favicon from sofarr-logoonly.png
Build and Push Docker Image / build (push) Successful in 25s
Generated favicon.ico (16/32/48px multi-size), favicon-32.png, and
favicon-192.png (apple-touch-icon/PWA) from the logo, centred on a
transparent square canvas. Linked all three in index.html with
appropriate rel/type/sizes attributes plus theme-color meta tag.
2026-05-16 15:34:24 +01:00
gronod 8b81f16dac fix: proper multi-user tag badges using full Emby user list
Build and Push Docker Image / build (push) Successful in 28s
Server:
- Add getEmbyUsers(): fetches all Emby users, builds Map of
  lowercase/sanitized name -> display name, cached 60s
- Add buildTagBadges(allTags, embyUserMap): classifies each tag
  as { label, matchedUser: displayName|null } against the full
  Emby user database
- Attach tagBadges[] to every download object when showAll=true
  (all 10 construction sites across SABnzbd queue/history and
  qBittorrent queue/history blocks)
- matchedUserTag still set to the tag matching the *current* user
  for the non-showAll badge

Frontend:
- showAll mode: renders tagBadges[] — unmatched tags (no Emby user)
  amber leftmost, matched tags show Emby display name in accent
  colour rightmost
- Normal mode: renders matchedUserTag badge only (current user's tag)
2026-05-16 15:29:50 +01:00
gronod 1f4aa19a72 fix: extractUserTag now correctly finds the tag matching the current user
Build and Push Docker Image / build (push) Successful in 27s
Previously extractUserTag returned the first tag in the list regardless
of whether it matched the logged-in user, so matchedUserTag was wrong
and unmatched tags weren't separated correctly.

- extractUserTag(tags, tagMap, username): finds tag label that matches
  username via tagMatchesUser(); returns null if no match
- extractAllTags(): moved before extractUserTag for readability
- All 10 call sites in user-downloads pass username arg
- user-summary uses extractAllTags() directly (wants all tags, not just
  the current user's) — as a bonus this now correctly counts items
  tagged for multiple users
2026-05-16 15:24:12 +01:00
gronod 43839fd8e3 fix: always show matched user tag badge, not just in showAll mode
Build and Push Docker Image / build (push) Successful in 24s
Unmatched amber badges still only appear when showAll is active.
2026-05-16 15:16:44 +01:00
gronod 24b7797b60 feat: multi-tag badges for showAll — amber for unmatched, accent for matched
Build and Push Docker Image / build (push) Successful in 27s
- server: add extractAllTags() returning all tag labels for a series/movie
- server: showAll now includes items with ANY tag (not just user-matched);
  non-admin path unchanged (must match current user's tag)
- server: replace userTag with allTags[] + matchedUserTag on every download object
- frontend: render all tags in header; unmatched tags amber (left), matched
  user tag in accent colour (rightmost); only visible in showAll mode
- css: add --unmatched-tag-bg/color variables to all three themes (light,
  dark, mono) and .download-user-badge.unmatched style
2026-05-16 15:14:33 +01:00
gronod de8563704a security: ensure log files excluded recursively from git and Docker builds (issue #16)
Build and Push Docker Image / build (push) Successful in 33s
*.log only matched root-level logs; add **/*.log to cover server/server.log
and any other subdirectory log files in both .gitignore and .dockerignore.
2026-05-16 15:08:44 +01:00
gronod 83049786eb security: fix issues #1-4 from security audit
Build and Push Docker Image / build (push) Successful in 39s
#1 Session cookie: add secure (production-only) and sameSite=strict
    to prevent transmission over HTTP and cross-site request abuse.
#2 Remove Emby AccessToken from cookie payload — it was stored in
    the browser cookie but is never needed client-side; reduces blast
    radius if cookie is ever exposed.
#3 Add requireAuth middleware to all proxy routes (/api/emby,
    /api/sabnzbd, /api/sonarr, /api/radarr) — previously unauthenticated,
    now require a valid emby_user session cookie.
#4 Remove open CORS wildcard (cors() with no options). The frontend
    is served from the same origin so no CORS headers are required.
    Also update clearCookie() to include matching cookie options.
2026-05-16 15:07:50 +01:00
30 changed files with 729 additions and 362 deletions
+2
View File
@@ -6,6 +6,7 @@ node_modules/
.gitignore
.DS_Store
*.log
**/*.log
client/
dist/
build/
@@ -13,3 +14,4 @@ README.md
.dockerignore
Dockerfile
.gitea/
docs/
+4
View File
@@ -2,6 +2,10 @@
PORT=3001
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)
# Set to 0 or "off" to disable and fetch on-demand instead
# POLL_INTERVAL=5000
+5
View File
@@ -14,6 +14,11 @@ PORT=3001
# - silent: No logging
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)
# sofarr polls all services in the background and caches results so
# dashboard requests are near-instant.
+26
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
View File
@@ -4,3 +4,4 @@ dist/
build/
.DS_Store
*.log
**/*.log
+31 -12
View File
@@ -107,6 +107,8 @@ sofarr/
│ │ ├── sabnzbd.js # Proxy routes to SABnzbd API
│ │ ├── sonarr.js # Proxy routes to Sonarr API
│ │ └── radarr.js # Proxy routes to Radarr API
│ ├── middleware/
│ │ └── requireAuth.js # httpOnly cookie auth middleware
│ └── utils/
│ ├── cache.js # MemoryCache class (Map + TTL + stats)
│ ├── config.js # Multi-instance service configuration parser
@@ -117,6 +119,9 @@ sofarr/
│ ├── index.html # HTML shell: splash, login, dashboard
│ ├── app.js # All frontend logic (auth, rendering, status)
│ ├── 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
├── Dockerfile # Production container image
├── docker-compose.yaml # Example compose deployment
@@ -135,7 +140,7 @@ Responsibilities:
- Load environment variables via `dotenv`
- Configure structured logging with level filtering (`LOG_LEVEL`)
- 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/*`
- Start the background poller
@@ -143,12 +148,14 @@ Responsibilities:
| Module | Mount Point | Auth Required | Purpose |
|--------|------------|---------------|---------|
| `auth.js` | `/api/auth` | No | Login, session check, logout |
| `dashboard.js` | `/api/dashboard` | Yes (cookie) | Aggregated download data, status |
| `emby.js` | `/api/emby` | No | Proxy to Emby API |
| `sabnzbd.js` | `/api/sabnzbd` | No | Proxy to SABnzbd API |
| `sonarr.js` | `/api/sonarr` | No | Proxy to Sonarr API |
| `radarr.js` | `/api/radarr` | No | Proxy to Radarr API |
| `auth.js` | `/api/auth` | No (public) | Login, session check, logout |
| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Aggregated download data, status |
| `emby.js` | `/api/emby` | Yes (`requireAuth`) | Proxy to Emby API |
| `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Proxy to SABnzbd API |
| `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Proxy to Sonarr 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.
@@ -208,7 +215,7 @@ When a user requests `/api/dashboard/user-downloads`:
1. User submits credentials via the login form
2. Backend calls Emby `POST /Users/authenticatebyname`
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
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-tags` | `[{id, label}]` | Radarr tag API |
| `poll:qbittorrent` | `[torrent, ...]` | qBittorrent all torrents |
| `emby:users` | `Map<lowerName, displayName>` | Full Emby user list (60s TTL) |
### TTL Strategy
@@ -314,7 +322,9 @@ Each matched download produces an object with:
| `eta` | string | Estimated time remaining |
| `seriesName` / `movieName` | string | Friendly media title |
| `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 |
| `downloadPath` | string / null | (Admin) Download client path |
| `targetPath` | string / null | (Admin) *arr target path |
@@ -346,7 +356,7 @@ Authenticate a user via Emby.
{ "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
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
@@ -473,7 +483,7 @@ The frontend is a **vanilla JavaScript SPA** with no build step. All logic resid
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
| `fetchUserDownloads()` | GET `/user-downloads`, update state, re-render |
| `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.) |
| `toggleStatusPanel()` | Show/hide admin status panel |
| `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`.
### 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
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.
+37 -9
View File
@@ -22,6 +22,12 @@ end note
:Build **sonarrTagMap** (tagId → label)
Build **radarrTagMap** (tagId → label);
if (showAll?) then (yes)
:Fetch full Emby user list
Build **embyUserMap** (lowerName → displayName)
[cached 60s];
endif
:Initialise **userDownloads** = [];
partition "Process SABnzbd Queue Slots" {
@@ -32,13 +38,20 @@ partition "Process SABnzbd Queue Slots" {
if (Title matches Sonarr **queue** record?) then (yes)
:series = seriesMap.get(match.seriesId)\n|| match.series;
if (series exists?) then (yes)
:userTag = extractUserTag(series.tags, sonarrTagMap);
if (showAll OR tagMatchesUser?) then (yes)
:allTags = extractAllTags(series.tags, sonarrTagMap)
matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
if (showAll AND hasAnyTag?) then (yes)
:Build download object (type=series)
Add coverArt, status, progress, speed, eta
Add allTags, matchedUserTag
Add tagBadges = buildTagBadges(allTags, embyUserMap)
Add importIssues if any
Add admin fields (paths, arrLink);
:Push to **userDownloads**;
elseif (NOT showAll AND matchedUserTag?) then (yes)
:Build download object (type=series)
Add matchedUserTag;
:Push to **userDownloads**;
endif
endif
endif
@@ -46,13 +59,19 @@ partition "Process SABnzbd Queue Slots" {
if (Title matches Radarr **queue** record?) then (yes)
:movie = moviesMap.get(match.movieId)\n|| match.movie;
if (movie exists?) then (yes)
:userTag = extractUserTag(movie.tags, radarrTagMap);
if (showAll OR tagMatchesUser?) then (yes)
:allTags = extractAllTags(movie.tags, radarrTagMap)
matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
if (showAll AND hasAnyTag?) then (yes)
:Build download object (type=movie)
Add coverArt, status, progress, speed, eta
Add allTags, matchedUserTag, tagBadges
Add importIssues if any
Add admin fields (paths, arrLink);
:Push to **userDownloads**;
elseif (NOT showAll AND matchedUserTag?) then (yes)
:Build download object (type=movie)
Add matchedUserTag;
:Push to **userDownloads**;
endif
endif
endif
@@ -67,16 +86,20 @@ partition "Process SABnzbd History Slots" {
if (Title matches Sonarr **history** record?) then (yes)
:series = seriesMap.get(match.seriesId)\n|| match.series;
if (series found?) then (yes)
:Check user tag, build download\n(type=series, with completedAt);
:Push to **userDownloads** if tag matches;
:extractAllTags + extractUserTag(username)
Build download (type=series, completedAt)
Add allTags, matchedUserTag, tagBadges if showAll;
:Push to **userDownloads** if showAll+anyTag or matchedUserTag;
endif
endif
if (Title matches Radarr **history** record?) then (yes)
:movie = moviesMap.get(match.movieId)\n|| match.movie;
if (movie found?) then (yes)
:Check user tag, build download\n(type=movie, with completedAt);
:Push to **userDownloads** if tag matches;
:extractAllTags + extractUserTag(username)
Build download (type=movie, completedAt)
Add allTags, matchedUserTag, tagBadges if showAll;
:Push to **userDownloads** if showAll+anyTag or matchedUserTag;
endif
endif
endwhile (no)
@@ -119,10 +142,15 @@ legend right
(bidirectional substring, case-insensitive):
""rTitle.includes(dlTitle) || dlTitle.includes(rTitle)""
**Tag Matching Logic**:
**Tag Matching Logic** (tagMatchesUser):
1. Exact: tag.toLowerCase() === username
2. Sanitised: sanitizeTagLabel(tag) === sanitizeTagLabel(username)
(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
@enduml
+12 -3
View File
@@ -153,7 +153,9 @@ package "sofarr Internal Models" {
+ movieName : string | null
+ episodeInfo : object | null
+ movieInfo : object | null
+ userTag : string
+ allTags : string[]
+ matchedUserTag : string | null
+ tagBadges : TagBadge[] | undefined
+ importIssues : string[] | null
+ downloadPath : string | null
+ targetPath : string | null
@@ -170,6 +172,11 @@ package "sofarr Internal Models" {
+ completedAt : string
}
class "TagBadge" as tagbadge <<value>> {
+ label : string
+ matchedUser : string | null
}
class "API Response\n/user-downloads" as apir {
+ user : string
+ isAdmin : boolean
@@ -201,7 +208,7 @@ package "sofarr Internal Models" {
+ id : string
+ name : string
+ isAdmin : boolean
+ token : string
' Note: Emby AccessToken intentionally excluded
}
apir *-- dl
@@ -215,7 +222,9 @@ sabh ..> dl : matched &\ntransformed
qbt ..> dl : mapTorrentToDownload()
ss ..> dl : coverArt, seriesName,\npath, tags
rm ..> dl : coverArt, movieName,\npath, tags
tag ..> dl : userTag resolution
tag ..> dl : allTags / matchedUserTag
eu ..> cookie : login creates
eu ..> tagbadge : buildTagBadges()
dl *-- tagbadge : tagBadges[]
@enduml
+25 -1
View File
@@ -33,7 +33,10 @@ package "server/routes" {
+ GET /status
--
- 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
- tagMatchesUser(tag, username) : boolean
- 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" {
class "MemoryCache" as cache {
- store : Map<string, CacheEntry>
@@ -158,6 +171,11 @@ package "server/utils" {
+ logToFile(message) : void
}
class "TagBadge" as tb <<value>> {
+ label : string
+ matchedUser : string | null
}
class "ClientInfo" as ci <<value>> {
+ user : string
+ refreshRateMs : number
@@ -172,6 +190,12 @@ ep --> emby_r
ep --> sab_r
ep --> sonarr_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()
dashboard --> cache : read/write
+8 -3
View File
@@ -16,10 +16,10 @@ package "Browser" as browser {
package "Express Server" as server {
package "Middleware" {
[CORS] as cors
[cookie-parser] as cp
[express.json] as ej
[express.static] as es
[requireAuth.js] as requireauth
}
package "Routes" as routes {
@@ -41,7 +41,6 @@ package "Express Server" as server {
[index.js\nEntry Point] as entry
entry --> cors
entry --> cp
entry --> ej
entry --> es
@@ -51,6 +50,12 @@ package "Express Server" as server {
entry --> sab_route
entry --> sonarr_route
entry --> radarr_route
emby_route --> requireauth
sab_route --> requireauth
sonarr_route --> requireauth
radarr_route --> requireauth
dashboard --> requireauth
entry --> poller : startPoller()
dashboard --> cache : read poll:* keys
@@ -76,7 +81,7 @@ cloud "External Services" as external {
}
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
sab_route --> sab
sonarr_route --> sonarr
+1 -1
View File
@@ -37,7 +37,7 @@ alt Valid credentials
auth -> emby : GET /Users/{userId}
emby --> auth : { Name, Policy: { IsAdministrator } }
deactivate emby
auth -> auth : Set httpOnly cookie\nemby_user = { id, name, isAdmin, token }\n(24h TTL)
auth -> 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 } }
browser -> browser : fadeOutLogin()
browser -> browser : showSplash()
+21 -6
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 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
loop each queue slot
dashboard -> dashboard : Match title vs Sonarr queue
dashboard -> dashboard : Match title vs Radarr queue
dashboard -> dashboard : Resolve series/movie\n→ extract user tag\n→ filter by username
dashboard -> dashboard : extractAllTags() + extractUserTag(username)\nInclude if: showAll+anyTag OR matchedUserTag\nAttach allTags, matchedUserTag\nIf showAll: tagBadges = buildTagBadges(embyUserMap)
end
end
group SABnzbd History Matching
loop each history slot
dashboard -> dashboard : Match title vs Sonarr history
dashboard -> dashboard : Match title vs Radarr history
dashboard -> dashboard : Resolve via seriesMap/moviesMap\n→ extract user tag\n→ filter
dashboard -> dashboard : Match title vs Sonarr/Radarr history
dashboard -> dashboard : Same tag extraction + inclusion logic
end
end
@@ -72,14 +81,20 @@ group qBittorrent Matching
dashboard -> dashboard : 2. Match vs Radarr queue
dashboard -> dashboard : 3. Match vs Sonarr history
dashboard -> dashboard : 4. Match vs Radarr history
dashboard -> dashboard : mapTorrentToDownload()\n→ enrich with series/movie info
dashboard -> dashboard : mapTorrentToDownload()\n→ enrich: allTags, matchedUserTag, tagBadges
end
end
dashboard --> browser : { user, isAdmin,\ndownloads: [...] }
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
@enduml
+120 -133
View File
@@ -1,12 +1,12 @@
{
"name": "media-download-dashboard",
"version": "1.0.0",
"name": "sofarr",
"version": "0.1.4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "media-download-dashboard",
"version": "1.0.0",
"name": "sofarr",
"version": "0.1.4",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
@@ -14,11 +14,12 @@
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"node-cron": "^3.0.3"
"express-rate-limit": "^6.7.0",
"helmet": "^4.6.0"
},
"devDependencies": {
"concurrently": "^7.6.0",
"nodemon": "^2.0.22"
"nodemon": "^3.1.14"
}
},
"node_modules/@babel/runtime": {
@@ -133,10 +134,13 @@
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
@@ -174,13 +178,15 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/braces": {
@@ -325,12 +331,6 @@
"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": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-7.6.0.tgz",
@@ -623,6 +623,17 @@
"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": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -836,6 +847,14 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1038,15 +1057,18 @@
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "*"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ms": {
@@ -1062,30 +1084,19 @@
"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": {
"version": "2.0.22",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz",
"integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==",
"version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
"integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
"dev": true,
"dependencies": {
"chokidar": "^3.5.2",
"debug": "^3.2.7",
"debug": "^4",
"ignore-by-default": "^1.0.1",
"minimatch": "^3.1.2",
"minimatch": "^10.2.1",
"pstree.remy": "^1.1.8",
"semver": "^5.7.1",
"simple-update-notifier": "^1.0.7",
"semver": "^7.5.3",
"simple-update-notifier": "^2.0.0",
"supports-color": "^5.5.0",
"touch": "^3.1.0",
"undefsafe": "^2.0.5"
@@ -1094,7 +1105,7 @@
"nodemon": "bin/nodemon.js"
},
"engines": {
"node": ">=8.10.0"
"node": ">=10"
},
"funding": {
"type": "opencollective",
@@ -1102,12 +1113,20 @@
}
},
"node_modules/nodemon/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"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": {
@@ -1318,12 +1337,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"dev": true,
"bin": {
"semver": "bin/semver"
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": {
@@ -1454,24 +1476,15 @@
}
},
"node_modules/simple-update-notifier": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz",
"integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
"dev": true,
"dependencies": {
"semver": "~7.0.0"
"semver": "^7.5.3"
},
"engines": {
"node": ">=8.10.0"
}
},
"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": ">=10"
}
},
"node_modules/spawn-command": {
@@ -1607,15 +1620,6 @@
"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": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -1764,9 +1768,9 @@
}
},
"balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true
},
"binary-extensions": {
@@ -1795,13 +1799,12 @@
}
},
"brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
"balanced-match": "^4.0.2"
}
},
"braces": {
@@ -1907,12 +1910,6 @@
"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": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-7.6.0.tgz",
@@ -2124,6 +2121,12 @@
"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": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -2259,6 +2262,11 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -2400,12 +2408,12 @@
}
},
"minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
"brace-expansion": "^5.0.5"
}
},
"ms": {
@@ -2418,39 +2426,31 @@
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"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": {
"version": "2.0.22",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz",
"integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==",
"version": "3.1.14",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
"integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
"dev": true,
"requires": {
"chokidar": "^3.5.2",
"debug": "^3.2.7",
"debug": "^4",
"ignore-by-default": "^1.0.1",
"minimatch": "^3.1.2",
"minimatch": "^10.2.1",
"pstree.remy": "^1.1.8",
"semver": "^5.7.1",
"simple-update-notifier": "^1.0.7",
"semver": "^7.5.3",
"simple-update-notifier": "^2.0.0",
"supports-color": "^5.5.0",
"touch": "^3.1.0",
"undefsafe": "^2.0.5"
},
"dependencies": {
"debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"requires": {
"ms": "^2.1.1"
"ms": "^2.1.3"
}
},
"has-flag": {
@@ -2595,9 +2595,9 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"dev": true
},
"send": {
@@ -2694,20 +2694,12 @@
}
},
"simple-update-notifier": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz",
"integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
"dev": true,
"requires": {
"semver": "~7.0.0"
},
"dependencies": {
"semver": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz",
"integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==",
"dev": true
}
"semver": "^7.5.3"
}
},
"spawn-command": {
@@ -2807,11 +2799,6 @@
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"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": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+11 -8
View File
@@ -1,24 +1,27 @@
{
"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",
"main": "server/index.js",
"scripts": {
"dev": "nodemon 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": {
"express": "^4.18.2",
"axios": "^1.6.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"axios": "^1.6.0",
"node-cron": "^3.0.3",
"cookie-parser": "^1.4.6"
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"helmet": "^4.6.0"
},
"devDependencies": {
"nodemon": "^2.0.22",
"concurrently": "^7.6.0"
"concurrently": "^7.6.0",
"nodemon": "^3.1.14"
},
"keywords": [
"sabnzbd",
+39 -7
View File
@@ -99,7 +99,15 @@ function dismissSplash(startTime) {
setTimeout(() => {
const splash = document.getElementById('splash-screen');
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', () => {
clearTimeout(fallback);
splash.style.display = 'none';
resolve();
}, { once: true });
@@ -136,6 +144,7 @@ async function handleLogin(e) {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('remember-me').checked;
try {
const response = await fetch('/api/auth/login', {
@@ -143,7 +152,7 @@ async function handleLogin(e) {
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
body: JSON.stringify({ username, password, rememberMe })
});
const data = await response.json();
@@ -151,9 +160,13 @@ async function handleLogin(e) {
if (data.success) {
currentUser = data.user;
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();
showSplash();
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
showDashboard();
const splashStart = Date.now();
await fetchUserDownloads(true);
@@ -434,11 +447,30 @@ function createDownloadCard(download) {
infoDiv.appendChild(movie);
}
if (showAll && download.userTag) {
const userBadge = document.createElement('span');
userBadge.className = 'download-user-badge';
userBadge.textContent = download.userTag;
header.appendChild(userBadge);
if (showAll && download.tagBadges && download.tagBadges.length > 0) {
// In showAll mode: render all tags classified by whether they match an Emby user.
// Unmatched (no known Emby user) → amber, leftmost.
// Matched → show Emby display name in accent colour, rightmost.
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');
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+10
View File
@@ -4,6 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
</head>
<body>
@@ -27,6 +31,12 @@
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
</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>
</form>
<div id="login-error" class="error-message" style="display: none;"></div>
+38
View File
@@ -64,6 +64,8 @@
--footer-text: rgba(255, 255, 255, 0.9);
--input-bg: #ffffff;
--select-bg: #ffffff;
--unmatched-tag-bg: #fff3e0;
--unmatched-tag-color: #e65100;
}
[data-theme="dark"] {
@@ -100,6 +102,8 @@
--footer-text: rgba(200, 200, 220, 0.8);
--input-bg: #2a2a3d;
--select-bg: #2a2a3d;
--unmatched-tag-bg: #3d2a00;
--unmatched-tag-color: #ffb74d;
}
[data-theme="mono"] {
@@ -136,6 +140,8 @@
--footer-text: rgba(180, 180, 180, 0.7);
--input-bg: #252525;
--select-bg: #252525;
--unmatched-tag-bg: #2a2a2a;
--unmatched-tag-color: #a0a0a0;
}
/* ===== Base ===== */
@@ -606,6 +612,32 @@ body {
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 {
width: 100%;
padding: 10px;
@@ -734,6 +766,12 @@ body {
white-space: nowrap;
}
.download-user-badge.unmatched {
background: var(--unmatched-tag-bg);
color: var(--unmatched-tag-color);
margin-left: 0;
}
/* ===== Status Button ===== */
.status-btn {
padding: 4px 12px;
+13 -3
View File
@@ -1,7 +1,7 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const cookieParser = require('cookie-parser');
const helmet = require('helmet');
const fs = require('fs');
require('dotenv').config();
@@ -59,8 +59,18 @@ const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller'
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(cookieParser());
app.use(helmet({
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.static(path.join(__dirname, '../public')));
+21
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;
+98 -45
View File
@@ -1,29 +1,57 @@
const express = require('express');
const axios = require('axios');
const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const router = express.Router();
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
router.post('/login', async (req, res) => {
router.post('/login', loginLimiter, async (req, res) => {
try {
const { username, password } = req.body;
const { username, password, rememberMe } = req.body;
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`, {
Username: username,
Pw: password
}, {
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;
console.log(`[Auth] Emby auth response:`, JSON.stringify(authData));
// Get user info using the access token
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;
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);
res.cookie('emby_user', JSON.stringify({
id: user.Id,
name: user.Name,
isAdmin: isAdmin,
token: authData.AccessToken
}), {
console.log(`[Auth] Login successful for user: ${user.Name}, isAdmin: ${isAdmin}`);
// Store token server-side; it is never sent to the client.
storeToken(user.Id, 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,
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({
success: true,
user: {
id: user.Id,
name: user.Name,
isAdmin: isAdmin
}
user: { id: user.Id, name: user.Name, isAdmin }
});
} catch (error) {
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
router.get('/me', (req, res) => {
try {
const userCookie = req.cookies.emby_user;
if (!userCookie) {
return res.json({ authenticated: false });
}
const user = JSON.parse(userCookie);
res.json({
authenticated: true,
user: {
id: user.id,
name: user.name,
isAdmin: !!user.isAdmin
}
});
} catch (error) {
console.error(`[Auth] Error getting current user:`, error.message);
res.json({ authenticated: false });
}
const user = parseSessionCookie(req);
if (!user) return res.json({ authenticated: false });
res.json({
authenticated: true,
user: { id: user.id, name: user.name, isAdmin: user.isAdmin }
});
});
// Logout
router.post('/logout', (req, res) => {
res.clearCookie('emby_user');
router.post('/logout', async (req, res) => {
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 });
});
+135 -79
View File
@@ -1,14 +1,14 @@
const express = require('express');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const axios = require('axios');
const { mapTorrentToDownload } = require('../utils/qbittorrent');
const cache = require('../utils/cache');
const { pollAllServices, getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
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
function getCoverArt(item) {
@@ -20,24 +20,26 @@ function getCoverArt(item) {
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
}
// Helper function to extract user tag from series/movie
// For Radarr: tags is array of IDs, tagMap is id -> label mapping
// For Sonarr: tags is array of objects with label property
function extractUserTag(tags, tagMap) {
if (!tags || tags.length === 0) return null;
// If tagMap provided (Radarr), look up label by ID
// Return all resolved tag labels for a series/movie.
// For Radarr: tags is array of IDs, tagMap is id -> label mapping.
// For Sonarr: tags are objects with a label property.
function extractAllTags(tags, tagMap) {
if (!tags || tags.length === 0) return [];
if (tagMap) {
for (const tagId of tags) {
const label = tagMap.get(tagId);
if (label) return label;
}
return null;
return tags.map(id => tagMap.get(id)).filter(Boolean);
}
// Sonarr style - tags are objects with label
const userTag = tags.find(tag => tag && tag.label);
return userTag ? userTag.label : null;
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;
}
// Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim
@@ -92,6 +94,41 @@ function getRadarrLink(movie) {
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 }>
const activeClients = new Map();
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
router.get('/user-downloads', async (req, res) => {
router.get('/user-downloads', requireAuth, async (req, res) => {
try {
// Get authenticated user from cookie
const userCookie = req.cookies.emby_user;
if (!userCookie) {
return res.status(401).json({ error: 'Not authenticated' });
}
const user = JSON.parse(userCookie);
const user = req.user;
const username = user.name.toLowerCase();
const usernameSanitized = sanitizeTagLabel(user.name);
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 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}`);
// Match SABnzbd downloads to Sonarr/Radarr activity
@@ -224,8 +258,10 @@ router.get('/user-downloads', async (req, res) => {
if (sonarrMatch && sonarrMatch.seriesId) {
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) {
const userTag = extractUserTag(series.tags, sonarrTagMap);
if (userTag && (showAll || tagMatchesUser(userTag, username))) {
const allTags = extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (showAll ? hasAnyTag : !!matchedUserTag) {
const dlObj = {
type: 'series',
title: nzbName,
@@ -239,7 +275,9 @@ router.get('/user-downloads', async (req, res) => {
eta: slot.timeleft,
seriesName: series.title,
episodeInfo: sonarrMatch,
userTag: userTag
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
};
const issues = getImportIssues(sonarrMatch);
if (issues) dlObj.importIssues = issues;
@@ -262,8 +300,10 @@ router.get('/user-downloads', async (req, res) => {
if (radarrMatch && radarrMatch.movieId) {
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
if (movie) {
const userTag = extractUserTag(movie.tags, radarrTagMap);
if (userTag && (showAll || tagMatchesUser(userTag, username))) {
const allTags = extractAllTags(movie.tags, radarrTagMap);
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (showAll ? hasAnyTag : !!matchedUserTag) {
const dlObj = {
type: 'movie',
title: nzbName,
@@ -277,7 +317,9 @@ router.get('/user-downloads', async (req, res) => {
eta: slot.timeleft,
movieName: movie.title,
movieInfo: radarrMatch,
userTag: userTag
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
};
const issues = getImportIssues(radarrMatch);
if (issues) dlObj.importIssues = issues;
@@ -317,8 +359,10 @@ router.get('/user-downloads', async (req, res) => {
if (sonarrMatch && sonarrMatch.seriesId) {
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) {
const userTag = extractUserTag(series.tags, sonarrTagMap);
if (userTag && (showAll || tagMatchesUser(userTag, username))) {
const allTags = extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (showAll ? hasAnyTag : !!matchedUserTag) {
const dlObj = {
type: 'series',
title: nzbName,
@@ -328,7 +372,9 @@ router.get('/user-downloads', async (req, res) => {
completedAt: slot.completed_time,
seriesName: series.title,
episodeInfo: sonarrMatch,
userTag: userTag
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
};
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
@@ -349,8 +395,10 @@ router.get('/user-downloads', async (req, res) => {
if (radarrMatch && radarrMatch.movieId) {
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
if (movie) {
const userTag = extractUserTag(movie.tags, radarrTagMap);
if (userTag && (showAll || tagMatchesUser(userTag, username))) {
const allTags = extractAllTags(movie.tags, radarrTagMap);
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
const hasAnyTag = allTags.length > 0;
if (showAll ? hasAnyTag : !!matchedUserTag) {
const dlObj = {
type: 'movie',
title: nzbName,
@@ -360,7 +408,9 @@ router.get('/user-downloads', async (req, res) => {
completedAt: slot.completed_time,
movieName: movie.title,
movieInfo: radarrMatch,
userTag: userTag
allTags,
matchedUserTag: matchedUserTag || null,
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
};
if (isAdmin) {
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)
const userMovies = Array.from(moviesMap.values()).filter(m => {
const tag = extractUserTag(m.tags, radarrTagMap);
return tag && tagMatchesUser(tag, username);
return !!extractUserTag(m.tags, radarrTagMap, username);
});
const userSeries = Array.from(seriesMap.values()).filter(s => {
const tag = extractUserTag(s.tags, sonarrTagMap);
return tag && tagMatchesUser(tag, username);
return !!extractUserTag(s.tags, sonarrTagMap, username);
});
console.log(`[Dashboard] Movies tagged for ${username}:`, userMovies.map(m => m.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) {
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) {
const userTag = extractUserTag(series.tags, sonarrTagMap);
if (userTag && (showAll || tagMatchesUser(userTag, username))) {
const allTags = extractAllTags(series.tags, sonarrTagMap);
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}"`);
const download = mapTorrentToDownload(torrent);
download.type = 'series';
download.coverArt = getCoverArt(series);
download.seriesName = series.title;
download.episodeInfo = sonarrMatch;
download.userTag = userTag;
download.allTags = allTags;
download.matchedUserTag = matchedUserTag || null;
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
const sonarrIssues = getImportIssues(sonarrMatch);
if (sonarrIssues) download.importIssues = sonarrIssues;
if (isAdmin) {
@@ -448,15 +500,19 @@ router.get('/user-downloads', async (req, res) => {
if (radarrMatch && radarrMatch.movieId) {
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
if (movie) {
const userTag = extractUserTag(movie.tags, radarrTagMap);
if (userTag && (showAll || tagMatchesUser(userTag, username))) {
const allTags = extractAllTags(movie.tags, radarrTagMap);
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}"`);
const download = mapTorrentToDownload(torrent);
download.type = 'movie';
download.coverArt = getCoverArt(movie);
download.movieName = movie.title;
download.movieInfo = radarrMatch;
download.userTag = userTag;
download.allTags = allTags;
download.matchedUserTag = matchedUserTag || null;
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
const radarrIssues = getImportIssues(radarrMatch);
if (radarrIssues) download.importIssues = radarrIssues;
if (isAdmin) {
@@ -479,15 +535,19 @@ router.get('/user-downloads', async (req, res) => {
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
if (series) {
const userTag = extractUserTag(series.tags, sonarrTagMap);
if (userTag && (showAll || tagMatchesUser(userTag, username))) {
const allTags = extractAllTags(series.tags, sonarrTagMap);
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}"`);
const download = mapTorrentToDownload(torrent);
download.type = 'series';
download.coverArt = getCoverArt(series);
download.seriesName = series.title;
download.episodeInfo = sonarrHistoryMatch;
download.userTag = userTag;
download.allTags = allTags;
download.matchedUserTag = matchedUserTag || null;
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = series.path || null;
@@ -508,15 +568,19 @@ router.get('/user-downloads', async (req, res) => {
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
if (movie) {
const userTag = extractUserTag(movie.tags, radarrTagMap);
if (userTag && (showAll || tagMatchesUser(userTag, username))) {
const allTags = extractAllTags(movie.tags, radarrTagMap);
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}"`);
const download = mapTorrentToDownload(torrent);
download.type = 'movie';
download.coverArt = getCoverArt(movie);
download.movieName = movie.title;
download.movieInfo = radarrHistoryMatch;
download.userTag = userTag;
download.allTags = allTags;
download.matchedUserTag = matchedUserTag || null;
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = movie.path || null;
@@ -547,19 +611,19 @@ router.get('/user-downloads', async (req, res) => {
} catch (error) {
console.error(`[Dashboard] Error fetching user downloads:`, error.message);
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
router.get('/user-summary', async (req, res) => {
router.get('/user-summary', requireAuth, async (req, res) => {
try {
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
// Get all Emby users
const usersResponse = await axios.get(`${EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
const usersResponse = await axios.get(`${process.env.EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
});
// Get all series, movies, and tags from all instances
@@ -603,40 +667,32 @@ router.get('/user-summary', async (req, res) => {
// Process series tags
allSeries.forEach(series => {
const userTag = extractUserTag(series.tags, sonarrTagMap);
if (userTag) {
const username = userTag.toLowerCase();
if (userDownloads[username]) {
userDownloads[username].seriesCount++;
}
}
const tags = extractAllTags(series.tags, sonarrTagMap);
tags.forEach(userTag => {
const uname = userTag.toLowerCase();
if (userDownloads[uname]) userDownloads[uname].seriesCount++;
});
});
// Process movie tags
allMovies.forEach(movie => {
const userTag = extractUserTag(movie.tags, radarrTagMap);
if (userTag) {
const username = userTag.toLowerCase();
if (userDownloads[username]) {
userDownloads[username].movieCount++;
}
}
const tags = extractAllTags(movie.tags, radarrTagMap);
tags.forEach(userTag => {
const uname = userTag.toLowerCase();
if (userDownloads[uname]) userDownloads[uname].movieCount++;
});
});
res.json(Object.values(userDownloads));
} 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
router.get('/status', (req, res) => {
router.get('/status', requireAuth, (req, res) => {
try {
const userCookie = req.cookies.emby_user;
if (!userCookie) {
return res.status(401).json({ error: 'Not authenticated' });
}
const user = JSON.parse(userCookie);
const user = req.user;
if (!user.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
+17 -16
View File
@@ -1,51 +1,52 @@
const express = require('express');
const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const EMBY_URL = process.env.EMBY_URL;
const EMBY_API_KEY = process.env.EMBY_API_KEY;
router.use(requireAuth);
// Get active sessions
router.get('/sessions', async (req, res) => {
try {
const response = await axios.get(`${EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
});
res.json(response.data);
} 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
router.get('/users/:id', async (req, res) => {
try {
const response = await axios.get(`${EMBY_URL}/Users/${req.params.id}`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
const response = await axios.get(`${process.env.EMBY_URL}/Users/${req.params.id}`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
});
res.json(response.data);
} 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
router.get('/users', async (req, res) => {
try {
const response = await axios.get(`${EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
});
res.json(response.data);
} 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
router.get('/session/:sessionId/user', async (req, res) => {
try {
const response = await axios.get(`${EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
});
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' });
}
const userResponse = await axios.get(`${EMBY_URL}/Users/${session.UserId}`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
const userResponse = await axios.get(`${process.env.EMBY_URL}/Users/${session.UserId}`, {
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
});
res.json(userResponse.data);
} 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) });
}
});
+15 -14
View File
@@ -1,56 +1,57 @@
const express = require('express');
const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const RADARR_URL = process.env.RADARR_URL;
const RADARR_API_KEY = process.env.RADARR_API_KEY;
router.use(requireAuth);
// Get queue
router.get('/queue', async (req, res) => {
try {
const response = await axios.get(`${RADARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': RADARR_API_KEY }
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} 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
router.get('/history', async (req, res) => {
try {
const response = await axios.get(`${RADARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': RADARR_API_KEY },
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY },
params: { pageSize: req.query.pageSize || 50 }
});
res.json(response.data);
} 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
router.get('/movies/:id', async (req, res) => {
try {
const response = await axios.get(`${RADARR_URL}/api/v3/movie/${req.params.id}`, {
headers: { 'X-Api-Key': RADARR_API_KEY }
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} 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
router.get('/movies', async (req, res) => {
try {
const response = await axios.get(`${RADARR_URL}/api/v3/movie`, {
headers: { 'X-Api-Key': RADARR_API_KEY }
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie`, {
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
});
res.json(response.data);
} 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) });
}
});
+9 -8
View File
@@ -1,40 +1,41 @@
const express = require('express');
const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const SABNZBD_URL = process.env.SABNZBD_URL;
const SABNZBD_API_KEY = process.env.SABNZBD_API_KEY;
router.use(requireAuth);
// Get current queue
router.get('/queue', async (req, res) => {
try {
const response = await axios.get(`${SABNZBD_URL}/api`, {
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
params: {
mode: 'queue',
apikey: SABNZBD_API_KEY,
apikey: process.env.SABNZBD_API_KEY,
output: 'json'
}
});
res.json(response.data);
} 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
router.get('/history', async (req, res) => {
try {
const response = await axios.get(`${SABNZBD_URL}/api`, {
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
params: {
mode: 'history',
apikey: SABNZBD_API_KEY,
apikey: process.env.SABNZBD_API_KEY,
output: 'json',
limit: req.query.limit || 50
}
});
res.json(response.data);
} 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) });
}
});
+15 -14
View File
@@ -1,56 +1,57 @@
const express = require('express');
const axios = require('axios');
const router = express.Router();
const requireAuth = require('../middleware/requireAuth');
const sanitizeError = require('../utils/sanitizeError');
const SONARR_URL = process.env.SONARR_URL;
const SONARR_API_KEY = process.env.SONARR_API_KEY;
router.use(requireAuth);
// Get queue
router.get('/queue', async (req, res) => {
try {
const response = await axios.get(`${SONARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': SONARR_API_KEY }
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} 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
router.get('/history', async (req, res) => {
try {
const response = await axios.get(`${SONARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': SONARR_API_KEY },
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY },
params: { pageSize: req.query.pageSize || 50 }
});
res.json(response.data);
} 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
router.get('/series/:id', async (req, res) => {
try {
const response = await axios.get(`${SONARR_URL}/api/v3/series/${req.params.id}`, {
headers: { 'X-Api-Key': SONARR_API_KEY }
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series/${req.params.id}`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} 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
router.get('/series', async (req, res) => {
try {
const response = await axios.get(`${SONARR_URL}/api/v3/series`, {
headers: { 'X-Api-Key': SONARR_API_KEY }
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series`, {
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
});
res.json(response.data);
} 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) });
}
});
+15
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;