Merge develop into main for v0.1.5
This commit is contained in:
@@ -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/
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
26
.gitea/workflows/ci.yml
Normal 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
1
.gitignore
vendored
@@ -4,3 +4,4 @@ dist/
|
|||||||
build/
|
build/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
**/*.log
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
253
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
19
package.json
19
package.json
@@ -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",
|
||||||
|
|||||||
@@ -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
BIN
public/favicon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
public/favicon-32.png
Normal file
BIN
public/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 543 B |
BIN
public/images/sofarr-logoonly.png
Normal file
BIN
public/images/sofarr-logoonly.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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')));
|
||||||
|
|
||||||
|
|||||||
21
server/middleware/requireAuth.js
Normal file
21
server/middleware/requireAuth.js
Normal 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;
|
||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,24 +20,26 @@ 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
|
||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
15
server/utils/sanitizeError.js
Normal file
15
server/utils/sanitizeError.js
Normal 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;
|
||||||
Reference in New Issue
Block a user