Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8eb49f64b6 | |||
| 6b8c215497 | |||
| 11749a428c | |||
| e83afde5ef | |||
| 031877e6a0 | |||
| 663826e295 | |||
| 14de5e4644 | |||
| 44cff5bf41 | |||
| bdfb042527 | |||
| b608fa0337 | |||
| 1f41114482 | |||
| 8fa20c6990 | |||
| d8584d0511 | |||
| 1eadb30481 | |||
| 8f96a5f296 | |||
| 6675e5dcfe | |||
| 54647ab7cf | |||
| 8b81f16dac | |||
| 1f4aa19a72 | |||
| 43839fd8e3 | |||
| 24b7797b60 | |||
| de8563704a | |||
| 83049786eb | |||
| 2137f65766 | |||
| 0ddb7a407e | |||
| 5ed547579d | |||
| 2bef9f9dee | |||
| fdecdd979b | |||
| e97bd3c67b | |||
| 0c8d5d8a4a | |||
| 268238215e | |||
| 31ff973eff | |||
| 1327c8e466 | |||
| c10d20d9f5 | |||
| d50a6fe19c | |||
| 6e3a98ae75 | |||
| 57e1db18e2 | |||
| c03e4620ea | |||
| e5b2fc8ea4 | |||
| 85bac5994e | |||
| f28d94d9a3 | |||
| 1574cce788 | |||
| b48332f075 | |||
| 3edc98b8d6 | |||
| c6b5aaf3de | |||
| c0478ed1b2 | |||
| b146a180d0 | |||
| bafa03aac2 | |||
| 59b096a60a | |||
| d09b0ab40a | |||
| 137d40affe | |||
| 84e4201dc1 | |||
| b75cd18580 | |||
| 36d183cba9 | |||
| b1f81eff0f | |||
| 78a8737f29 | |||
| d5542abd27 | |||
| 980e20247c | |||
| ba43a3d6bd | |||
| b44d370b51 | |||
| 6e81180175 | |||
| 5ae6af114e | |||
| c97f232290 | |||
| 6d35ce06e0 | |||
| 6d6969190a | |||
| fe73589633 | |||
| 6baf643645 | |||
| 570eca0b82 | |||
| b04b52e3f1 | |||
| eda9770f49 | |||
| efa66b9fd6 | |||
| fd8335b683 | |||
| 4c1b11c3cc | |||
| c6d1a7ffed | |||
| 9b0e778392 | |||
| d31f108821 | |||
| b590513f94 | |||
| ebb73492c4 | |||
| 8b526aa13b |
@@ -6,6 +6,7 @@ node_modules/
|
||||
.gitignore
|
||||
.DS_Store
|
||||
*.log
|
||||
**/*.log
|
||||
client/
|
||||
dist/
|
||||
build/
|
||||
@@ -13,3 +14,4 @@ README.md
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
.gitea/
|
||||
docs/
|
||||
+10
-4
@@ -1,5 +1,14 @@
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Cookie signing secret for tamper-proof session cookies
|
||||
# Required in production. Generate with: openssl rand -hex 32
|
||||
COOKIE_SECRET=your_cookie_secret_here
|
||||
|
||||
# Background polling interval in ms (default: 5000)
|
||||
# Set to 0 or "off" to disable and fetch on-demand instead
|
||||
# POLL_INTERVAL=5000
|
||||
|
||||
# Emby Configuration (single instance)
|
||||
EMBY_URL=http://localhost:8096
|
||||
@@ -16,7 +25,4 @@ SONARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:8989", "apiKey":
|
||||
RADARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:7878", "apiKey": "your_api_key"}]
|
||||
|
||||
# qBittorrent Instances (JSON array)
|
||||
QBITTORRENT_INSTANCES=[
|
||||
{"name": "ransackedcrew", "url": "https://qbittorrent.ransackedcrew.info", "username": "gronod", "password": "K32D&JDjtHA&mC"},
|
||||
{"name": "i3omb", "url": "https://qbittorrent.i3omb.com", "username": "admin", "password": "b053288369XX!"}
|
||||
]
|
||||
QBITTORRENT_INSTANCES=[{"name": "main", "url": "http://localhost:8080", "username": "admin", "password": "your_password"}]
|
||||
|
||||
+14
@@ -14,6 +14,19 @@ PORT=3001
|
||||
# - silent: No logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Cookie signing secret for tamper-proof session cookies
|
||||
# Required in production (server exits on startup if unset).
|
||||
# Generate with: openssl rand -hex 32
|
||||
COOKIE_SECRET=your-cookie-secret-here
|
||||
|
||||
# Background polling interval in milliseconds (default: 5000)
|
||||
# sofarr polls all services in the background and caches results so
|
||||
# dashboard requests are near-instant.
|
||||
# Set to 0, "off", "false", or "disabled" to disable background polling.
|
||||
# When disabled, data is fetched on-demand when a user opens the dashboard
|
||||
# and cached for 30 seconds so other users benefit from the same fetch.
|
||||
# POLL_INTERVAL=5000
|
||||
|
||||
# =============================================================================
|
||||
# EMBY (Authentication - Required)
|
||||
# =============================================================================
|
||||
@@ -74,4 +87,5 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
|
||||
# 3. URLs should include protocol (http:// or https://)
|
||||
# 4. For qBittorrent, ensure Web UI is enabled in settings
|
||||
# 5. User downloads are matched by tags in Sonarr/Radarr - tag your media!
|
||||
# 6. Background polling keeps data fresh; disable it for low-resource setups
|
||||
# =============================================================================
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'release/**'
|
||||
- 'develop'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -12,32 +13,31 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from package.json
|
||||
id: version
|
||||
- name: Compute image tags
|
||||
id: meta
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
BRANCH=${GITHUB_REF#refs/heads/}
|
||||
RELEASE_NAME=${BRANCH#release/}
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "release=${RELEASE_NAME}" >> $GITHUB_OUTPUT
|
||||
echo "Building version ${VERSION} from branch ${BRANCH}"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
if [ "$BRANCH" = "develop" ]; then
|
||||
echo "tags=reg.i3omb.com/sofarr:develop" >> $GITHUB_OUTPUT
|
||||
echo "Building develop image (version ${VERSION})"
|
||||
else
|
||||
RELEASE_NAME=${BRANCH#release/}
|
||||
TAGS="reg.i3omb.com/sofarr:${VERSION}"
|
||||
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
|
||||
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
|
||||
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||
echo "Building release image ${VERSION} from branch ${BRANCH}"
|
||||
fi
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
reg.i3omb.com/sofarr:${{ steps.version.outputs.version }}
|
||||
reg.i3omb.com/sofarr:${{ steps.version.outputs.release }}
|
||||
reg.i3omb.com/sofarr:latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
||||
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
|
||||
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
|
||||
|
||||
@@ -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
|
||||
@@ -4,3 +4,4 @@ dist/
|
||||
build/
|
||||
.DS_Store
|
||||
*.log
|
||||
**/*.log
|
||||
|
||||
@@ -108,6 +108,7 @@ docker run -d \
|
||||
-e SABNZBD_INSTANCES='[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]' \
|
||||
-e QBITTORRENT_INSTANCES='[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]' \
|
||||
-e LOG_LEVEL=info \
|
||||
-e POLL_INTERVAL=5000 \
|
||||
docker.i3omb.com/sofarr:latest
|
||||
```
|
||||
|
||||
@@ -130,6 +131,7 @@ services:
|
||||
- SABNZBD_INSTANCES=[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]
|
||||
- QBITTORRENT_INSTANCES=[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]
|
||||
- LOG_LEVEL=info
|
||||
- POLL_INTERVAL=5000
|
||||
```
|
||||
|
||||
> **Tip:** You can also use a combination — mount a `.env` file for base config, and override specific values with `-e` flags. Environment variables always take precedence.
|
||||
@@ -181,6 +183,8 @@ Open `http://localhost:3001` in your browser
|
||||
```bash
|
||||
PORT=3001 # Server port
|
||||
LOG_LEVEL=info # Logging: debug, info, warn, error, silent
|
||||
POLL_INTERVAL=5000 # Background polling interval in ms (default: 5000)
|
||||
# Set to 0 or "off" to disable (on-demand mode)
|
||||
```
|
||||
|
||||
### Service Instances (JSON Array Format)
|
||||
@@ -231,6 +235,18 @@ To see your downloads, you need to tag your media in Sonarr/Radarr:
|
||||
|
||||
## Features in Detail
|
||||
|
||||
### Background Polling
|
||||
|
||||
sofarr polls all configured services in the background and caches the results. Dashboard requests read from cache, making them near-instant regardless of how many services you have.
|
||||
|
||||
| Setting | Behaviour |
|
||||
|---------|----------|
|
||||
| `POLL_INTERVAL=5000` (default) | Background poller fetches every 5s. Dashboard reads from cache instantly. |
|
||||
| `POLL_INTERVAL=10000` | Poll every 10 seconds. |
|
||||
| `POLL_INTERVAL=0` / `off` / `disabled` | No background polling. Data fetched on-demand when a user opens the dashboard, then cached for 30 seconds. |
|
||||
|
||||
**On-demand mode** is useful for low-resource setups. When one user's browser refreshes, the fetched data is cached and served to all other users until it expires. A user with a faster refresh rate effectively keeps the cache warm for everyone.
|
||||
|
||||
### Real-Time Updates
|
||||
- Auto-refresh every 5 seconds (configurable: 1s, 5s, 10s, or off)
|
||||
- In-place DOM updates for smooth UI (no flickering)
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
# sofarr — Architecture Documentation
|
||||
|
||||
Comprehensive technical documentation covering the full architecture of the **sofarr** application: a personal media download dashboard that aggregates downloads from SABnzbd, Sonarr, Radarr, and qBittorrent, filtered by Emby user identity.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [System Overview](#1-system-overview)
|
||||
2. [Technology Stack](#2-technology-stack)
|
||||
3. [Directory Structure](#3-directory-structure)
|
||||
4. [Component Architecture](#4-component-architecture)
|
||||
5. [Data Flow](#5-data-flow)
|
||||
6. [Authentication & Authorisation](#6-authentication--authorisation)
|
||||
7. [Background Polling & Caching](#7-background-polling--caching)
|
||||
8. [Download Matching Pipeline](#8-download-matching-pipeline)
|
||||
9. [API Reference](#9-api-reference)
|
||||
10. [Frontend Architecture](#10-frontend-architecture)
|
||||
11. [Configuration](#11-configuration)
|
||||
12. [Deployment](#12-deployment)
|
||||
13. [UML Diagrams (PlantUML)](#13-uml-diagrams-plantuml)
|
||||
|
||||
---
|
||||
|
||||
## 1. System Overview
|
||||
|
||||
sofarr is a **single-page web application** with a Node.js/Express backend. It provides a personalised view of media downloads by:
|
||||
|
||||
1. **Authenticating** users against an Emby/Jellyfin media server.
|
||||
2. **Aggregating** download data from multiple *arr service instances and download clients.
|
||||
3. **Filtering** downloads by user — each user only sees media tagged with their username in Sonarr/Radarr.
|
||||
4. **Presenting** a real-time dashboard with progress, speeds, cover art, and status.
|
||||
|
||||
Admin users can view all users' downloads, see server status, cache statistics, and poll timings.
|
||||
|
||||
### High-Level Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Browser (SPA) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
|
||||
│ │ Login │ │Dashboard │ │ Status Panel │ │
|
||||
│ │ Form │ │ Cards │ │ (Admin only) │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └───────┬───────────┘ │
|
||||
│ │ │ │ │
|
||||
└───────┼──────────────┼────────────────┼──────────────┘
|
||||
│ POST /login │ GET /user- │ GET /status
|
||||
│ │ downloads │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Express Server (:3001) │
|
||||
│ ┌────────┐ ┌──────────┐ ┌────────┐ ┌────────────┐ │
|
||||
│ │ Auth │ │Dashboard │ │ Emby │ │ Static │ │
|
||||
│ │ Routes │ │ Routes │ │ Routes │ │ Files │ │
|
||||
│ └────┬───┘ └────┬─────┘ └────┬───┘ └────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌────┴──────────┴────────────┴──────────────────┐ │
|
||||
│ │ Utilities Layer │ │
|
||||
│ │ ┌────────┐ ┌────────┐ ┌──────┐ ┌──────────┐ │ │
|
||||
│ │ │ Poller │ │ Cache │ │Config│ │qBittorrent│ │ │
|
||||
│ │ └───┬────┘ └────────┘ └──────┘ └──────────┘ │ │
|
||||
│ └──────┼────────────────────────────────────────┘ │
|
||||
└─────────┼────────────────────────────────────────────┘
|
||||
│ HTTP/API calls
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ External Services │
|
||||
│ ┌──────────┐ ┌────────┐ ┌────────┐ ┌────────────┐ │
|
||||
│ │ SABnzbd │ │ Sonarr │ │ Radarr │ │qBittorrent │ │
|
||||
│ │ (Usenet) │ │ (TV) │ │(Movie) │ │ (Torrent) │ │
|
||||
│ └──────────┘ └────────┘ └────────┘ └────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ Emby / Jellyfin │ │
|
||||
│ │ (Authentication + User DB) │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Technology Stack
|
||||
|
||||
| Layer | Technology | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| **Runtime** | Node.js 18+ | Server runtime |
|
||||
| **Framework** | Express 4.x | HTTP server, routing, middleware |
|
||||
| **HTTP Client** | axios 1.x | External API communication |
|
||||
| **Frontend** | Vanilla JS + CSS | Single-page app, no build step |
|
||||
| **Auth** | Emby API + httpOnly cookies | Session management |
|
||||
| **Caching** | In-memory Map with TTL | Reduce external API load |
|
||||
| **Scheduling** | `setInterval` | Background polling |
|
||||
| **Containerisation** | Docker (Alpine) | Production deployment |
|
||||
| **Logging** | Custom logger + `console.*` | File + stdout logging with levels |
|
||||
|
||||
---
|
||||
|
||||
## 3. Directory Structure
|
||||
|
||||
```
|
||||
sofarr/
|
||||
├── server/ # Backend application
|
||||
│ ├── index.js # Entry point: Express setup, middleware, startup
|
||||
│ ├── routes/
|
||||
│ │ ├── auth.js # POST /login, GET /me, POST /logout
|
||||
│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status
|
||||
│ │ ├── emby.js # Proxy routes to Emby API
|
||||
│ │ ├── sabnzbd.js # Proxy routes to SABnzbd API
|
||||
│ │ ├── sonarr.js # Proxy routes to Sonarr API
|
||||
│ │ └── radarr.js # Proxy routes to Radarr API
|
||||
│ ├── middleware/
|
||||
│ │ └── requireAuth.js # httpOnly cookie auth middleware
|
||||
│ └── utils/
|
||||
│ ├── cache.js # MemoryCache class (Map + TTL + stats)
|
||||
│ ├── config.js # Multi-instance service configuration parser
|
||||
│ ├── logger.js # File logger (server.log)
|
||||
│ ├── poller.js # Background polling engine + timing
|
||||
│ └── qbittorrent.js # qBittorrent client with auth + torrent mapping
|
||||
├── public/ # Static frontend (served by Express)
|
||||
│ ├── index.html # HTML shell: splash, login, dashboard
|
||||
│ ├── app.js # All frontend logic (auth, rendering, status)
|
||||
│ ├── style.css # Themes, layout, responsive design
|
||||
│ ├── favicon.ico # Multi-size favicon (16/32/48px)
|
||||
│ ├── favicon-32.png # 32px PNG favicon
|
||||
│ ├── favicon-192.png # 192px PNG (apple-touch-icon / PWA)
|
||||
│ └── images/ # Logo / splash screen assets
|
||||
├── Dockerfile # Production container image
|
||||
├── docker-compose.yaml # Example compose deployment
|
||||
├── package.json # Dependencies and scripts
|
||||
├── .env.sample # Annotated environment variable template
|
||||
└── README.md # User-facing documentation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Component Architecture
|
||||
|
||||
### 4.1 Server Entry Point (`server/index.js`)
|
||||
|
||||
Responsibilities:
|
||||
- Load environment variables via `dotenv`
|
||||
- Configure structured logging with level filtering (`LOG_LEVEL`)
|
||||
- Redirect `console.*` to both stdout and `server.log`
|
||||
- Mount Express middleware (cookie-parser, JSON, static files)
|
||||
- Mount route modules under `/api/*`
|
||||
- Start the background poller
|
||||
|
||||
### 4.2 Route Modules
|
||||
|
||||
| Module | Mount Point | Auth Required | Purpose |
|
||||
|--------|------------|---------------|---------|
|
||||
| `auth.js` | `/api/auth` | No (public) | Login, session check, logout |
|
||||
| `dashboard.js` | `/api/dashboard` | Yes (`requireAuth`) | Aggregated download data, status |
|
||||
| `emby.js` | `/api/emby` | Yes (`requireAuth`) | Proxy to Emby API |
|
||||
| `sabnzbd.js` | `/api/sabnzbd` | Yes (`requireAuth`) | Proxy to SABnzbd API |
|
||||
| `sonarr.js` | `/api/sonarr` | Yes (`requireAuth`) | Proxy to Sonarr API |
|
||||
| `radarr.js` | `/api/radarr` | Yes (`requireAuth`) | Proxy to Radarr API |
|
||||
|
||||
`requireAuth` (`server/middleware/requireAuth.js`) reads the `emby_user` httpOnly cookie and attaches the parsed user to `req.user`. Returns `401` if the cookie is absent or malformed.
|
||||
|
||||
> **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) use legacy single-instance env vars and are largely unused by the dashboard — they exist for direct API access. The dashboard reads from the poller cache.
|
||||
|
||||
### 4.3 Utility Modules
|
||||
|
||||
**`config.js`** — Parses service instances from environment variables. Supports both JSON array format (`SONARR_INSTANCES=[{...}]`) and legacy single-instance format (`SONARR_URL` + `SONARR_API_KEY`). Each instance gets an `id` derived from `name` or index.
|
||||
|
||||
**`cache.js`** — Singleton `MemoryCache` backed by a `Map`. Each entry has an expiration timestamp. Provides `get`, `set`, `invalidate`, `clear`, and `getStats` (returns per-key size, item count, TTL remaining).
|
||||
|
||||
**`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand per dashboard request.
|
||||
|
||||
**`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities.
|
||||
|
||||
**`logger.js`** — Simple file appender writing timestamped messages to `server.log`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Flow
|
||||
|
||||
### 5.1 Polling Cycle
|
||||
|
||||
Every `POLL_INTERVAL` ms (default 5000), the poller fetches from all services in parallel:
|
||||
|
||||
| Task | API Call | Params |
|
||||
|------|----------|--------|
|
||||
| SABnzbd Queue | `GET /api?mode=queue` | `output=json` |
|
||||
| SABnzbd History | `GET /api?mode=history` | `limit=10` |
|
||||
| Sonarr Tags | `GET /api/v3/tag` | — |
|
||||
| Sonarr Queue | `GET /api/v3/queue` | `includeSeries=true` |
|
||||
| Sonarr History | `GET /api/v3/history` | `pageSize=10` |
|
||||
| Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` |
|
||||
| Radarr History | `GET /api/v3/history` | `pageSize=10` |
|
||||
| Radarr Tags | `GET /api/v3/tag` | — |
|
||||
| qBittorrent | `GET /api/v2/torrents/info` | — |
|
||||
|
||||
Results are stored in the cache under `poll:*` keys with a TTL of `POLL_INTERVAL × 3`.
|
||||
|
||||
### 5.2 Dashboard Request
|
||||
|
||||
When a user requests `/api/dashboard/user-downloads`:
|
||||
|
||||
1. Read all `poll:*` keys from cache
|
||||
2. Build `seriesMap` and `moviesMap` from embedded objects in queue records
|
||||
3. Build `sonarrTagMap` and `radarrTagMap` from tag data
|
||||
4. For each SABnzbd queue slot → try to match against Sonarr/Radarr queue records by title
|
||||
5. For each SABnzbd history slot → try to match against Sonarr/Radarr history records
|
||||
6. For each qBittorrent torrent → try to match against Sonarr/Radarr queue, then history
|
||||
7. For each match, resolve the series/movie, extract the user tag, check if it belongs to the requesting user
|
||||
8. Return only the user's downloads (or all, if admin with `showAll=true`)
|
||||
|
||||
---
|
||||
|
||||
## 6. Authentication & Authorisation
|
||||
|
||||
### Flow
|
||||
|
||||
1. User submits credentials via the login form
|
||||
2. Backend calls Emby `POST /Users/authenticatebyname`
|
||||
3. On success, fetches full user profile to determine admin status
|
||||
4. Sets an `httpOnly` cookie (`emby_user`) containing: `{ id, name, isAdmin }` — the Emby `AccessToken` is intentionally **not** stored in the cookie
|
||||
5. Cookie expires after 24 hours
|
||||
6. All subsequent dashboard requests read this cookie for identity
|
||||
|
||||
### Authorisation Matrix
|
||||
|
||||
| Feature | Regular User | Admin |
|
||||
|---------|:----------:|:-----:|
|
||||
| View own downloads | ✓ | ✓ |
|
||||
| View all users' downloads | ✗ | ✓ (`showAll`) |
|
||||
| See download/target paths | ✗ | ✓ |
|
||||
| See Sonarr/Radarr links | ✗ | ✓ |
|
||||
| View status panel | ✗ | ✓ |
|
||||
|
||||
### Tag Matching
|
||||
|
||||
Users are matched to downloads via tags in Sonarr/Radarr:
|
||||
|
||||
1. **Exact match**: tag label (lowercased) === username (lowercased)
|
||||
2. **Sanitised match**: handles Ombi's tag mangling — `sanitizeTagLabel()` converts to lowercase, replaces non-alphanumeric with hyphens, collapses, trims
|
||||
|
||||
---
|
||||
|
||||
## 7. Background Polling & Caching
|
||||
|
||||
### Polling Modes
|
||||
|
||||
| Mode | `POLL_INTERVAL` | Behaviour |
|
||||
|------|----------------|-----------|
|
||||
| **Background** | `> 0` (e.g. `5000`) | Periodic fetch every N ms |
|
||||
| **On-demand** | `0` / `off` / `false` | Fetch triggered by first user request when cache is empty |
|
||||
|
||||
### Cache Keys
|
||||
|
||||
| Key | Content | Source |
|
||||
|-----|---------|--------|
|
||||
| `poll:sab-queue` | `{ slots, status, speed, kbpersec }` | SABnzbd queue |
|
||||
| `poll:sab-history` | `{ slots }` | SABnzbd history |
|
||||
| `poll:sonarr-tags` | `[{ instance, data: [{id, label}] }]` | Sonarr tag API |
|
||||
| `poll:sonarr-queue` | `{ records }` — includes embedded `series` objects | Sonarr queue (includeSeries) |
|
||||
| `poll:sonarr-history` | `{ records }` — lightweight, no embedded objects | Sonarr history |
|
||||
| `poll:radarr-queue` | `{ records }` — includes embedded `movie` objects | Radarr queue (includeMovie) |
|
||||
| `poll:radarr-history` | `{ records }` — lightweight, no embedded objects | Radarr history |
|
||||
| `poll:radarr-tags` | `[{id, label}]` | Radarr tag API |
|
||||
| `poll:qbittorrent` | `[torrent, ...]` | qBittorrent all torrents |
|
||||
| `emby:users` | `Map<lowerName, displayName>` | Full Emby user list (60s TTL) |
|
||||
|
||||
### TTL Strategy
|
||||
|
||||
- **Polling enabled**: `POLL_INTERVAL × 3` — ensures data survives between polls even if one poll is slow
|
||||
- **Polling disabled**: `30000` ms — stale after 30s, next request triggers a fresh fetch
|
||||
|
||||
### Active Client Tracking
|
||||
|
||||
Each dashboard request reports the client's refresh rate. The server tracks active clients in a `Map<username, { user, refreshRateMs, lastSeen }>`, pruning entries older than 30 seconds. This data powers the admin status panel's "effective refresh mode" display.
|
||||
|
||||
---
|
||||
|
||||
## 8. Download Matching Pipeline
|
||||
|
||||
The core logic in `dashboard.js` matches raw download client data to *arr service metadata to determine which user each download belongs to.
|
||||
|
||||
### Matching Strategy
|
||||
|
||||
For each download item (SABnzbd slot or qBittorrent torrent):
|
||||
|
||||
```
|
||||
1. Try Sonarr QUEUE match (by title substring)
|
||||
→ resolve series via seriesMap (embedded in queue record)
|
||||
→ extract user tag → check tag matches requesting user
|
||||
|
||||
2. Try Radarr QUEUE match (by title substring)
|
||||
→ resolve movie via moviesMap (embedded in queue record)
|
||||
→ extract user tag → check tag matches requesting user
|
||||
|
||||
3. Try Sonarr HISTORY match (by title substring)
|
||||
→ resolve series via seriesMap (from queue) using seriesId
|
||||
→ extract user tag → check tag matches requesting user
|
||||
|
||||
4. Try Radarr HISTORY match (by title substring)
|
||||
→ resolve movie via moviesMap (from queue) using movieId
|
||||
→ extract user tag → check tag matches requesting user
|
||||
```
|
||||
|
||||
### Title Matching
|
||||
|
||||
Matches are **bidirectional substring matches** (case-insensitive):
|
||||
```javascript
|
||||
rTitle.includes(downloadTitle) || downloadTitle.includes(rTitle)
|
||||
```
|
||||
|
||||
### Download Object Structure
|
||||
|
||||
Each matched download produces an object with:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `type` | `'series'` / `'movie'` / `'torrent'` | Media type |
|
||||
| `title` | string | Raw download title |
|
||||
| `coverArt` | string / null | Poster URL from *arr |
|
||||
| `status` | string | Download status |
|
||||
| `progress` | string | Percentage complete |
|
||||
| `size` / `mb` / `mbmissing` | string / number | Size info |
|
||||
| `speed` | string | Current download speed |
|
||||
| `eta` | string | Estimated time remaining |
|
||||
| `seriesName` / `movieName` | string | Friendly media title |
|
||||
| `episodeInfo` / `movieInfo` | object | Full *arr queue/history record |
|
||||
| `allTags` | string[] | All resolved tag labels on the series/movie |
|
||||
| `matchedUserTag` | string / null | Tag label matching the requesting user, or `null` |
|
||||
| `tagBadges` | `{label, matchedUser}[]` / undefined | (Admin `showAll` only) Each tag classified against full Emby user list |
|
||||
| `importIssues` | string[] / null | Import warning/error messages |
|
||||
| `downloadPath` | string / null | (Admin) Download client path |
|
||||
| `targetPath` | string / null | (Admin) *arr target path |
|
||||
| `arrLink` | string / null | (Admin) Link to *arr web UI |
|
||||
|
||||
---
|
||||
|
||||
## 9. API Reference
|
||||
|
||||
### `POST /api/auth/login`
|
||||
|
||||
Authenticate a user via Emby.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{ "username": "string", "password": "string" }
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"user": { "id": "string", "name": "string", "isAdmin": true }
|
||||
}
|
||||
```
|
||||
|
||||
**Response (401):**
|
||||
```json
|
||||
{ "success": false, "error": "Invalid username or password" }
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/auth/me`
|
||||
|
||||
Check current session.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"user": { "id": "string", "name": "string", "isAdmin": false }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/auth/logout`
|
||||
|
||||
Clear session cookie.
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/dashboard/user-downloads`
|
||||
|
||||
Fetch downloads for the authenticated user.
|
||||
|
||||
**Query Parameters:**
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `showAll` | `"true"` | (Admin) Show all users' downloads |
|
||||
| `refreshRate` | number (ms) | Client's current refresh rate for tracking |
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"user": "string",
|
||||
"isAdmin": true,
|
||||
"downloads": [ /* download objects */ ]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/dashboard/status`
|
||||
|
||||
Admin-only server status.
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"uptimeSeconds": 3600,
|
||||
"nodeVersion": "v18.19.0",
|
||||
"memoryUsageMB": 45.2,
|
||||
"heapUsedMB": 28.1,
|
||||
"heapTotalMB": 35.0
|
||||
},
|
||||
"polling": {
|
||||
"enabled": true,
|
||||
"intervalMs": 5000,
|
||||
"lastPoll": {
|
||||
"totalMs": 1234,
|
||||
"timestamp": "2026-05-16T00:00:00.000Z",
|
||||
"tasks": [
|
||||
{ "label": "SABnzbd Queue", "ms": 120 },
|
||||
{ "label": "Sonarr Queue", "ms": 890 }
|
||||
]
|
||||
}
|
||||
},
|
||||
"cache": {
|
||||
"entryCount": 9,
|
||||
"totalSizeBytes": 51200,
|
||||
"entries": [
|
||||
{ "key": "poll:sab-queue", "sizeBytes": 1024, "itemCount": 3, "ttlRemainingMs": 12000, "expired": false }
|
||||
]
|
||||
},
|
||||
"clients": [
|
||||
{ "user": "Alice", "refreshRateMs": 5000, "lastSeen": 1715817600000 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `GET /api/dashboard/user-summary`
|
||||
|
||||
Admin-only per-user download counts (fetches live from APIs, not cached).
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
[
|
||||
{ "username": "Alice", "seriesCount": 12, "movieCount": 5 }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Frontend Architecture
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Splash Screen│────▶│ Login Form │────▶│ Dashboard │
|
||||
│ (on load) │ │ (if no │ │ (after auth) │
|
||||
│ │ │ session) │ │ │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
│
|
||||
┌─────┴─────┐
|
||||
│ Status │
|
||||
│ Panel │
|
||||
│ (admin) │
|
||||
└───────────┘
|
||||
```
|
||||
|
||||
### Key Frontend Functions
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `checkAuthentication()` | On load: check session → show dashboard or login |
|
||||
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
|
||||
| `fetchUserDownloads()` | GET `/user-downloads`, update state, re-render |
|
||||
| `renderDownloads()` | Diff-based card rendering (create/update/remove) |
|
||||
| `createDownloadCard()` | Build DOM for a single download card; renders tag badges |
|
||||
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
|
||||
| `toggleStatusPanel()` | Show/hide admin status panel |
|
||||
| `renderStatusPanel()` | Build status HTML (server, polling, cache, clients) |
|
||||
| `startAutoRefresh()` | Start periodic `fetchUserDownloads` |
|
||||
| `initThemeSwitcher()` | Light / Dark / Mono theme support |
|
||||
|
||||
### Themes
|
||||
|
||||
Three CSS themes via `data-theme` attribute on `<html>`:
|
||||
- **Light** — Purple gradient header, white cards
|
||||
- **Dark** — Dark surfaces, muted accents
|
||||
- **Mono** — Monochrome, minimal colour
|
||||
|
||||
Theme selection persists in `localStorage`.
|
||||
|
||||
### Tag Badge Rendering
|
||||
|
||||
Download cards render tag badges in the card header:
|
||||
|
||||
- **Normal user view**: a single accent-coloured badge showing the tag label that matched the current user's username (via `matchedUserTag`).
|
||||
- **Admin `showAll` view**: all tags on the download are rendered using `tagBadges[]`:
|
||||
- Tags with **no matching Emby user** → amber badge showing the raw tag label (leftmost)
|
||||
- Tags **matched to a known Emby user** → accent badge showing the Emby display name (rightmost)
|
||||
|
||||
### Auto-Refresh
|
||||
|
||||
The dashboard polls the server at the user-selected interval (1s, 5s, 10s, or Off). The status panel auto-refreshes in sync. Client refresh rate is sent to the server on each request for active-client tracking.
|
||||
|
||||
---
|
||||
|
||||
## 11. Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|:--------:|---------|-------------|
|
||||
| `PORT` | No | `3001` | Server listen port |
|
||||
| `EMBY_URL` | Yes | — | Emby/Jellyfin server URL |
|
||||
| `EMBY_API_KEY` | Yes | — | Emby API key |
|
||||
| `SONARR_INSTANCES` | Yes* | — | JSON array of Sonarr instances |
|
||||
| `SONARR_URL` | Yes* | — | Legacy single Sonarr URL |
|
||||
| `SONARR_API_KEY` | Yes* | — | Legacy single Sonarr API key |
|
||||
| `RADARR_INSTANCES` | Yes* | — | JSON array of Radarr instances |
|
||||
| `RADARR_URL` | Yes* | — | Legacy single Radarr URL |
|
||||
| `RADARR_API_KEY` | Yes* | — | Legacy single Radarr API key |
|
||||
| `SABNZBD_INSTANCES` | Yes* | — | JSON array of SABnzbd instances |
|
||||
| `SABNZBD_URL` | Yes* | — | Legacy single SABnzbd URL |
|
||||
| `SABNZBD_API_KEY` | Yes* | — | Legacy single SABnzbd API key |
|
||||
| `QBITTORRENT_INSTANCES` | No | — | JSON array of qBittorrent instances |
|
||||
| `POLL_INTERVAL` | No | `5000` | Poll interval in ms, or `off`/`0` to disable |
|
||||
| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` |
|
||||
|
||||
\* Either `*_INSTANCES` (JSON array) or legacy `*_URL` + `*_API_KEY` format is required.
|
||||
|
||||
### Instance JSON Format
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "main",
|
||||
"url": "https://sonarr.example.com",
|
||||
"apiKey": "your-api-key"
|
||||
},
|
||||
{
|
||||
"name": "4k",
|
||||
"url": "https://sonarr4k.example.com",
|
||||
"apiKey": "your-4k-api-key"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
qBittorrent instances use `username` and `password` instead of `apiKey`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Deployment
|
||||
|
||||
### Docker
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY server/ ./server/
|
||||
COPY public/ ./public/
|
||||
EXPOSE 3001
|
||||
ENV NODE_ENV=production
|
||||
CMD ["node", "server/index.js"]
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
services:
|
||||
sofarr:
|
||||
image: docker.i3omb.com/sofarr:latest
|
||||
container_name: sofarr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
- EMBY_URL=https://emby.example.com
|
||||
- EMBY_API_KEY=your-emby-api-key
|
||||
- SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
||||
- RADARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
||||
- SABNZBD_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
|
||||
- QBITTORRENT_INSTANCES=[{"name":"main","url":"...","username":"...","password":"..."}]
|
||||
- POLL_INTERVAL=5000
|
||||
- LOG_LEVEL=info
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. UML Diagrams (PlantUML)
|
||||
|
||||
All diagrams below are in PlantUML syntax. Render with any PlantUML tool or VS Code extension.
|
||||
|
||||
### 13.1 Component Diagram
|
||||
|
||||
See [`diagrams/component.puml`](diagrams/component.puml)
|
||||
|
||||
### 13.2 Sequence Diagrams
|
||||
|
||||
- **Authentication**: [`diagrams/seq-auth.puml`](diagrams/seq-auth.puml)
|
||||
- **Dashboard Request**: [`diagrams/seq-dashboard.puml`](diagrams/seq-dashboard.puml)
|
||||
- **Polling Cycle**: [`diagrams/seq-polling.puml`](diagrams/seq-polling.puml)
|
||||
|
||||
### 13.3 Class / Entity Diagrams
|
||||
|
||||
- **Server Classes**: [`diagrams/class-server.puml`](diagrams/class-server.puml)
|
||||
- **Data Model**: [`diagrams/class-data.puml`](diagrams/class-data.puml)
|
||||
|
||||
### 13.4 State Diagrams
|
||||
|
||||
- **Frontend UI States**: [`diagrams/state-ui.puml`](diagrams/state-ui.puml)
|
||||
- **Poller States**: [`diagrams/state-poller.puml`](diagrams/state-poller.puml)
|
||||
|
||||
### 13.5 Activity Diagram
|
||||
|
||||
- **Download Matching**: [`diagrams/activity-matching.puml`](diagrams/activity-matching.puml)
|
||||
@@ -0,0 +1,156 @@
|
||||
@startuml activity-matching
|
||||
!theme plain
|
||||
title sofarr — Download Matching Activity Diagram
|
||||
|
||||
start
|
||||
|
||||
:Read cached data from MemoryCache;
|
||||
note right
|
||||
poll:sab-queue, poll:sab-history,
|
||||
poll:sonarr-queue, poll:sonarr-history,
|
||||
poll:radarr-queue, poll:radarr-history,
|
||||
poll:sonarr-tags, poll:radarr-tags,
|
||||
poll:qbittorrent
|
||||
end note
|
||||
|
||||
:Build **seriesMap** from Sonarr queue records
|
||||
(seriesId → embedded series object);
|
||||
|
||||
:Build **moviesMap** from Radarr queue records
|
||||
(movieId → embedded movie object);
|
||||
|
||||
:Build **sonarrTagMap** (tagId → label)
|
||||
Build **radarrTagMap** (tagId → label);
|
||||
|
||||
if (showAll?) then (yes)
|
||||
:Fetch full Emby user list
|
||||
Build **embyUserMap** (lowerName → displayName)
|
||||
[cached 60s];
|
||||
endif
|
||||
|
||||
:Initialise **userDownloads** = [];
|
||||
|
||||
partition "Process SABnzbd Queue Slots" {
|
||||
while (More queue slots?) is (yes)
|
||||
:Get slot filename (nzbName);
|
||||
:nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
if (Title matches Sonarr **queue** record?) then (yes)
|
||||
:series = seriesMap.get(match.seriesId)\n|| match.series;
|
||||
if (series exists?) then (yes)
|
||||
:allTags = extractAllTags(series.tags, sonarrTagMap)
|
||||
matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
if (showAll AND hasAnyTag?) then (yes)
|
||||
:Build download object (type=series)
|
||||
Add coverArt, status, progress, speed, eta
|
||||
Add allTags, matchedUserTag
|
||||
Add tagBadges = buildTagBadges(allTags, embyUserMap)
|
||||
Add importIssues if any
|
||||
Add admin fields (paths, arrLink);
|
||||
:Push to **userDownloads**;
|
||||
elseif (NOT showAll AND matchedUserTag?) then (yes)
|
||||
:Build download object (type=series)
|
||||
Add matchedUserTag;
|
||||
:Push to **userDownloads**;
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
|
||||
if (Title matches Radarr **queue** record?) then (yes)
|
||||
:movie = moviesMap.get(match.movieId)\n|| match.movie;
|
||||
if (movie exists?) then (yes)
|
||||
:allTags = extractAllTags(movie.tags, radarrTagMap)
|
||||
matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
if (showAll AND hasAnyTag?) then (yes)
|
||||
:Build download object (type=movie)
|
||||
Add coverArt, status, progress, speed, eta
|
||||
Add allTags, matchedUserTag, tagBadges
|
||||
Add importIssues if any
|
||||
Add admin fields (paths, arrLink);
|
||||
:Push to **userDownloads**;
|
||||
elseif (NOT showAll AND matchedUserTag?) then (yes)
|
||||
:Build download object (type=movie)
|
||||
Add matchedUserTag;
|
||||
:Push to **userDownloads**;
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
endwhile (no)
|
||||
}
|
||||
|
||||
partition "Process SABnzbd History Slots" {
|
||||
while (More history slots?) is (yes)
|
||||
:Get slot name (nzbName);
|
||||
:nzbNameLower = nzbName.toLowerCase();
|
||||
|
||||
if (Title matches Sonarr **history** record?) then (yes)
|
||||
:series = seriesMap.get(match.seriesId)\n|| match.series;
|
||||
if (series found?) then (yes)
|
||||
:extractAllTags + extractUserTag(username)
|
||||
Build download (type=series, completedAt)
|
||||
Add allTags, matchedUserTag, tagBadges if showAll;
|
||||
:Push to **userDownloads** if showAll+anyTag or matchedUserTag;
|
||||
endif
|
||||
endif
|
||||
|
||||
if (Title matches Radarr **history** record?) then (yes)
|
||||
:movie = moviesMap.get(match.movieId)\n|| match.movie;
|
||||
if (movie found?) then (yes)
|
||||
:extractAllTags + extractUserTag(username)
|
||||
Build download (type=movie, completedAt)
|
||||
Add allTags, matchedUserTag, tagBadges if showAll;
|
||||
:Push to **userDownloads** if showAll+anyTag or matchedUserTag;
|
||||
endif
|
||||
endif
|
||||
endwhile (no)
|
||||
}
|
||||
|
||||
partition "Process qBittorrent Torrents" {
|
||||
while (More torrents?) is (yes)
|
||||
:Get torrent name;
|
||||
:torrentNameLower = name.toLowerCase();
|
||||
|
||||
if (Matches Sonarr **queue**?) then (yes)
|
||||
:Resolve series → check tag;
|
||||
:mapTorrentToDownload() + enrich;
|
||||
:Push if matches → **continue**;
|
||||
elseif (Matches Radarr **queue**?) then (yes)
|
||||
:Resolve movie → check tag;
|
||||
:mapTorrentToDownload() + enrich;
|
||||
:Push if matches → **continue**;
|
||||
elseif (Matches Sonarr **history**?) then (yes)
|
||||
:Resolve series via seriesMap;
|
||||
:mapTorrentToDownload() + enrich;
|
||||
:Push if matches → **continue**;
|
||||
elseif (Matches Radarr **history**?) then (yes)
|
||||
:Resolve movie via moviesMap;
|
||||
:mapTorrentToDownload() + enrich;
|
||||
:Push if matches → **continue**;
|
||||
else (no match)
|
||||
:Skip torrent (unmatched);
|
||||
endif
|
||||
endwhile (no)
|
||||
}
|
||||
|
||||
:Return JSON response
|
||||
{ user, isAdmin, downloads: userDownloads };
|
||||
|
||||
stop
|
||||
|
||||
legend right
|
||||
**Title Matching Logic**
|
||||
(bidirectional substring, case-insensitive):
|
||||
""rTitle.includes(dlTitle) || dlTitle.includes(rTitle)""
|
||||
|
||||
**Tag Matching Logic** (tagMatchesUser):
|
||||
1. Exact: tag.toLowerCase() === username
|
||||
2. Sanitised: sanitizeTagLabel(tag) === sanitizeTagLabel(username)
|
||||
(handles Ombi-mangled email-style usernames)
|
||||
|
||||
**extractAllTags**: returns all resolved tag labels
|
||||
**extractUserTag**: returns the ONE label matching current user
|
||||
**buildTagBadges**: classifies each tag against full Emby user
|
||||
list → { label, matchedUser: displayName | null }
|
||||
end legend
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,230 @@
|
||||
@startuml class-data
|
||||
!theme plain
|
||||
title sofarr — Data Model Diagram
|
||||
|
||||
skinparam classAttributeIconSize 0
|
||||
|
||||
package "External API Responses" {
|
||||
class "SABnzbd Queue Slot" as sabq {
|
||||
+ filename : string
|
||||
+ nzbname : string
|
||||
+ percentage : string
|
||||
+ mb : string
|
||||
+ mbmissing : string
|
||||
+ size : string
|
||||
+ timeleft : string
|
||||
+ status : string
|
||||
+ storage : string
|
||||
}
|
||||
|
||||
class "SABnzbd History Slot" as sabh {
|
||||
+ name : string
|
||||
+ nzb_name : string
|
||||
+ nzbname : string
|
||||
+ status : string
|
||||
+ size : string
|
||||
+ completed_time : string
|
||||
+ storage : string
|
||||
}
|
||||
|
||||
class "Sonarr Queue Record" as sqr {
|
||||
+ id : number
|
||||
+ seriesId : number
|
||||
+ series : SonarrSeries
|
||||
+ title : string
|
||||
+ sourceTitle : string
|
||||
+ trackedDownloadStatus : string
|
||||
+ trackedDownloadState : string
|
||||
+ statusMessages : StatusMessage[]
|
||||
+ errorMessage : string
|
||||
}
|
||||
|
||||
class "Sonarr History Record" as shr {
|
||||
+ id : number
|
||||
+ seriesId : number
|
||||
+ title : string
|
||||
+ sourceTitle : string
|
||||
+ eventType : string
|
||||
}
|
||||
|
||||
class "SonarrSeries" as ss {
|
||||
+ id : number
|
||||
+ title : string
|
||||
+ titleSlug : string
|
||||
+ path : string
|
||||
+ tags : number[]
|
||||
+ images : Image[]
|
||||
+ _instanceUrl : string
|
||||
}
|
||||
|
||||
class "Radarr Queue Record" as rqr {
|
||||
+ id : number
|
||||
+ movieId : number
|
||||
+ movie : RadarrMovie
|
||||
+ title : string
|
||||
+ sourceTitle : string
|
||||
+ trackedDownloadStatus : string
|
||||
+ trackedDownloadState : string
|
||||
+ statusMessages : StatusMessage[]
|
||||
+ errorMessage : string
|
||||
}
|
||||
|
||||
class "Radarr History Record" as rhr {
|
||||
+ id : number
|
||||
+ movieId : number
|
||||
+ title : string
|
||||
+ sourceTitle : string
|
||||
+ eventType : string
|
||||
}
|
||||
|
||||
class "RadarrMovie" as rm {
|
||||
+ id : number
|
||||
+ title : string
|
||||
+ titleSlug : string
|
||||
+ path : string
|
||||
+ tags : number[]
|
||||
+ images : Image[]
|
||||
+ _instanceUrl : string
|
||||
}
|
||||
|
||||
class "Tag" as tag {
|
||||
+ id : number
|
||||
+ label : string
|
||||
}
|
||||
|
||||
class "Image" as img {
|
||||
+ coverType : string
|
||||
+ remoteUrl : string
|
||||
+ url : string
|
||||
}
|
||||
|
||||
class "StatusMessage" as sm {
|
||||
+ title : string
|
||||
+ messages : string[]
|
||||
}
|
||||
|
||||
class "qBittorrent Torrent" as qbt {
|
||||
+ name : string
|
||||
+ hash : string
|
||||
+ size : number
|
||||
+ completed : number
|
||||
+ progress : number (0-1)
|
||||
+ state : string
|
||||
+ dlspeed : number
|
||||
+ eta : number
|
||||
+ num_seeds : number
|
||||
+ num_leechs : number
|
||||
+ availability : number
|
||||
+ category : string
|
||||
+ tags : string
|
||||
+ save_path : string
|
||||
+ content_path : string
|
||||
+ instanceId : string
|
||||
+ instanceName : string
|
||||
}
|
||||
|
||||
class "Emby User" as eu {
|
||||
+ Id : string
|
||||
+ Name : string
|
||||
+ Policy : { IsAdministrator: boolean }
|
||||
}
|
||||
|
||||
sqr *-- ss : embedded\n(includeSeries)
|
||||
rqr *-- rm : embedded\n(includeMovie)
|
||||
sqr *-- sm
|
||||
rqr *-- sm
|
||||
ss *-- img
|
||||
rm *-- img
|
||||
}
|
||||
|
||||
package "sofarr Internal Models" {
|
||||
class "Download Object" as dl {
|
||||
+ type : 'series' | 'movie' | 'torrent'
|
||||
+ title : string
|
||||
+ coverArt : string | null
|
||||
+ status : string
|
||||
+ progress : string
|
||||
+ mb : string
|
||||
+ mbmissing : string
|
||||
+ size : string
|
||||
+ speed : string
|
||||
+ eta : string
|
||||
+ seriesName : string | null
|
||||
+ movieName : string | null
|
||||
+ episodeInfo : object | null
|
||||
+ movieInfo : object | null
|
||||
+ allTags : string[]
|
||||
+ matchedUserTag : string | null
|
||||
+ tagBadges : TagBadge[] | undefined
|
||||
+ importIssues : string[] | null
|
||||
+ downloadPath : string | null
|
||||
+ targetPath : string | null
|
||||
+ arrLink : string | null
|
||||
+ qbittorrent : boolean
|
||||
+ seeds : number
|
||||
+ peers : number
|
||||
+ availability : string
|
||||
+ rawSize : number
|
||||
+ rawSpeed : number
|
||||
+ rawEta : number
|
||||
+ hash : string
|
||||
+ category : string
|
||||
+ completedAt : string
|
||||
}
|
||||
|
||||
class "TagBadge" as tagbadge <<value>> {
|
||||
+ label : string
|
||||
+ matchedUser : string | null
|
||||
}
|
||||
|
||||
class "API Response\n/user-downloads" as apir {
|
||||
+ user : string
|
||||
+ isAdmin : boolean
|
||||
+ downloads : Download[]
|
||||
}
|
||||
|
||||
class "Status Response\n/status" as statr {
|
||||
+ server : ServerInfo
|
||||
+ polling : PollingInfo
|
||||
+ cache : CacheStats
|
||||
+ clients : ClientInfo[]
|
||||
}
|
||||
|
||||
class "ServerInfo" as si {
|
||||
+ uptimeSeconds : number
|
||||
+ nodeVersion : string
|
||||
+ memoryUsageMB : number
|
||||
+ heapUsedMB : number
|
||||
+ heapTotalMB : number
|
||||
}
|
||||
|
||||
class "PollingInfo" as pi {
|
||||
+ enabled : boolean
|
||||
+ intervalMs : number
|
||||
+ lastPoll : PollTimings
|
||||
}
|
||||
|
||||
class "Session Cookie\nemby_user" as cookie {
|
||||
+ id : string
|
||||
+ name : string
|
||||
+ isAdmin : boolean
|
||||
' Note: Emby AccessToken intentionally excluded
|
||||
}
|
||||
|
||||
apir *-- dl
|
||||
statr *-- si
|
||||
statr *-- pi
|
||||
}
|
||||
|
||||
' Data flow connections
|
||||
sabq ..> dl : matched &\ntransformed
|
||||
sabh ..> dl : matched &\ntransformed
|
||||
qbt ..> dl : mapTorrentToDownload()
|
||||
ss ..> dl : coverArt, seriesName,\npath, tags
|
||||
rm ..> dl : coverArt, movieName,\npath, tags
|
||||
tag ..> dl : allTags / matchedUserTag
|
||||
eu ..> cookie : login creates
|
||||
eu ..> tagbadge : buildTagBadges()
|
||||
dl *-- tagbadge : tagBadges[]
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,221 @@
|
||||
@startuml class-server
|
||||
!theme plain
|
||||
title sofarr — Server Class / Module Diagram
|
||||
|
||||
package "server/index.js" as entry {
|
||||
class "EntryPoint" as ep <<module>> {
|
||||
- LOG_LEVELS : Object
|
||||
- currentLevel : number
|
||||
- logFile : WriteStream
|
||||
+ shouldLog(level) : boolean
|
||||
--
|
||||
Configures Express app,
|
||||
mounts routes, starts poller
|
||||
}
|
||||
}
|
||||
|
||||
package "server/routes" {
|
||||
class "auth.js" as auth <<router>> {
|
||||
+ POST /login
|
||||
+ GET /me
|
||||
+ POST /logout
|
||||
--
|
||||
Authenticates via Emby API
|
||||
Sets/reads httpOnly cookie
|
||||
}
|
||||
|
||||
class "dashboard.js" as dashboard <<router>> {
|
||||
- activeClients : Map<string, ClientInfo>
|
||||
- CLIENT_STALE_MS : 30000
|
||||
--
|
||||
+ GET /user-downloads
|
||||
+ GET /user-summary
|
||||
+ GET /status
|
||||
--
|
||||
- getCoverArt(item) : string|null
|
||||
- extractAllTags(tags, tagMap) : string[]
|
||||
- extractUserTag(tags, tagMap, username) : string|null
|
||||
- buildTagBadges(allTags, embyUserMap) : TagBadge[]
|
||||
- getEmbyUsers() : Promise<Map>
|
||||
- sanitizeTagLabel(input) : string
|
||||
- tagMatchesUser(tag, username) : boolean
|
||||
- getImportIssues(record) : string[]|null
|
||||
- getSonarrLink(series) : string|null
|
||||
- getRadarrLink(movie) : string|null
|
||||
- getActiveClients() : ClientInfo[]
|
||||
}
|
||||
|
||||
class "emby.js" as emby_r <<router>> {
|
||||
+ GET /sessions
|
||||
+ GET /users/:id
|
||||
+ GET /users
|
||||
+ GET /session/:sessionId/user
|
||||
}
|
||||
|
||||
class "sabnzbd.js" as sab_r <<router>> {
|
||||
+ GET /queue
|
||||
+ GET /history
|
||||
}
|
||||
|
||||
class "sonarr.js" as sonarr_r <<router>> {
|
||||
+ GET /queue
|
||||
+ GET /history
|
||||
+ GET /series/:id
|
||||
+ GET /series
|
||||
}
|
||||
|
||||
class "radarr.js" as radarr_r <<router>> {
|
||||
+ GET /queue
|
||||
+ GET /history
|
||||
+ GET /movies/:id
|
||||
+ GET /movies
|
||||
}
|
||||
}
|
||||
|
||||
package "server/middleware" {
|
||||
class "requireAuth.js" as requireauth <<middleware>> {
|
||||
+ requireAuth(req, res, next) : void
|
||||
--
|
||||
Reads emby_user cookie
|
||||
Attaches parsed user to req.user
|
||||
Returns 401 if absent/invalid
|
||||
}
|
||||
}
|
||||
|
||||
package "server/utils" {
|
||||
class "MemoryCache" as cache {
|
||||
- store : Map<string, CacheEntry>
|
||||
+ get(key) : any|null
|
||||
+ set(key, value, ttlMs) : void
|
||||
+ invalidate(key) : void
|
||||
+ clear() : void
|
||||
+ getStats() : CacheStats
|
||||
}
|
||||
|
||||
class "CacheEntry" as ce <<value>> {
|
||||
+ value : any
|
||||
+ expiresAt : number
|
||||
}
|
||||
|
||||
class "CacheStats" as cs <<value>> {
|
||||
+ entryCount : number
|
||||
+ totalSizeBytes : number
|
||||
+ entries : CacheEntryStats[]
|
||||
}
|
||||
|
||||
class "Poller" as poller <<module>> {
|
||||
- POLL_INTERVAL : number
|
||||
- POLLING_ENABLED : boolean
|
||||
- polling : boolean
|
||||
- lastPollTimings : PollTimings|null
|
||||
- intervalHandle : number|null
|
||||
--
|
||||
+ startPoller() : void
|
||||
+ stopPoller() : void
|
||||
+ pollAllServices() : Promise<void>
|
||||
+ getLastPollTimings() : PollTimings|null
|
||||
--
|
||||
- timed(label, fn) : TimedResult
|
||||
}
|
||||
|
||||
class "PollTimings" as pt <<value>> {
|
||||
+ totalMs : number
|
||||
+ timestamp : string (ISO)
|
||||
+ tasks : { label, ms }[]
|
||||
}
|
||||
|
||||
class "Config" as config <<module>> {
|
||||
+ getSABnzbdInstances() : Instance[]
|
||||
+ getSonarrInstances() : Instance[]
|
||||
+ getRadarrInstances() : Instance[]
|
||||
+ getQbittorrentInstances() : Instance[]
|
||||
--
|
||||
- parseInstances(envVar, ...) : Instance[]
|
||||
}
|
||||
|
||||
class "Instance" as inst <<value>> {
|
||||
+ id : string
|
||||
+ name : string
|
||||
+ url : string
|
||||
+ apiKey : string
|
||||
+ username? : string
|
||||
+ password? : string
|
||||
}
|
||||
|
||||
class "QBittorrentClient" as qbt {
|
||||
- id : string
|
||||
- name : string
|
||||
- url : string
|
||||
- username : string
|
||||
- password : string
|
||||
- authCookie : string|null
|
||||
--
|
||||
+ login() : Promise<boolean>
|
||||
+ makeRequest(endpoint, config) : Promise<Response>
|
||||
+ getTorrents() : Promise<Torrent[]>
|
||||
}
|
||||
|
||||
class "qbittorrent.js" as qbt_mod <<module>> {
|
||||
- persistedClients : QBittorrentClient[]|null
|
||||
--
|
||||
+ getTorrents() : Promise<Torrent[]>
|
||||
+ getClients() : QBittorrentClient[]
|
||||
+ mapTorrentToDownload(torrent) : Download
|
||||
+ formatBytes(bytes) : string
|
||||
+ formatSpeed(bps) : string
|
||||
+ formatEta(seconds) : string
|
||||
}
|
||||
|
||||
class "Logger" as logger <<module>> {
|
||||
- logFile : WriteStream
|
||||
+ logToFile(message) : void
|
||||
}
|
||||
|
||||
class "TagBadge" as tb <<value>> {
|
||||
+ label : string
|
||||
+ matchedUser : string | null
|
||||
}
|
||||
|
||||
class "ClientInfo" as ci <<value>> {
|
||||
+ user : string
|
||||
+ refreshRateMs : number
|
||||
+ lastSeen : number (timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
' Relationships
|
||||
ep --> auth
|
||||
ep --> dashboard
|
||||
ep --> emby_r
|
||||
ep --> sab_r
|
||||
ep --> sonarr_r
|
||||
ep --> radarr_r
|
||||
|
||||
dashboard --> requireauth : uses
|
||||
emby_r --> requireauth : uses
|
||||
sab_r --> requireauth : uses
|
||||
sonarr_r --> requireauth : uses
|
||||
radarr_r --> requireauth : uses
|
||||
ep --> poller : startPoller()
|
||||
|
||||
dashboard --> cache : read/write
|
||||
dashboard --> poller : pollAllServices()
|
||||
dashboard --> qbt_mod : mapTorrentToDownload()
|
||||
dashboard --> config
|
||||
|
||||
poller --> cache : set poll:* keys
|
||||
poller --> config : get instances
|
||||
poller --> qbt_mod : getTorrents()
|
||||
|
||||
qbt_mod --> config : getQbittorrentInstances()
|
||||
qbt_mod *-- qbt : creates
|
||||
qbt --> logger
|
||||
|
||||
cache *-- ce : stores
|
||||
cache ..> cs : returns from getStats()
|
||||
poller ..> pt : stores/returns
|
||||
dashboard *-- ci : stores in activeClients
|
||||
|
||||
config ..> inst : returns
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,99 @@
|
||||
@startuml component
|
||||
!theme plain
|
||||
title sofarr — Component Diagram
|
||||
|
||||
skinparam componentStyle rectangle
|
||||
skinparam packageStyle frame
|
||||
|
||||
package "Browser" as browser {
|
||||
[index.html] as html
|
||||
[app.js] as appjs
|
||||
[style.css] as css
|
||||
html ..> appjs : loads
|
||||
html ..> css : loads
|
||||
}
|
||||
|
||||
package "Express Server" as server {
|
||||
|
||||
package "Middleware" {
|
||||
[cookie-parser] as cp
|
||||
[express.json] as ej
|
||||
[express.static] as es
|
||||
[requireAuth.js] as requireauth
|
||||
}
|
||||
|
||||
package "Routes" as routes {
|
||||
[auth.js\n/api/auth] as auth
|
||||
[dashboard.js\n/api/dashboard] as dashboard
|
||||
[emby.js\n/api/emby] as emby_route
|
||||
[sabnzbd.js\n/api/sabnzbd] as sab_route
|
||||
[sonarr.js\n/api/sonarr] as sonarr_route
|
||||
[radarr.js\n/api/radarr] as radarr_route
|
||||
}
|
||||
|
||||
package "Utilities" as utils {
|
||||
[poller.js] as poller
|
||||
[cache.js\nMemoryCache] as cache
|
||||
[config.js] as config
|
||||
[qbittorrent.js\nQBittorrentClient] as qbt
|
||||
[logger.js] as logger
|
||||
}
|
||||
|
||||
[index.js\nEntry Point] as entry
|
||||
|
||||
entry --> cp
|
||||
entry --> ej
|
||||
entry --> es
|
||||
entry --> auth
|
||||
entry --> dashboard
|
||||
entry --> emby_route
|
||||
entry --> sab_route
|
||||
entry --> sonarr_route
|
||||
entry --> radarr_route
|
||||
|
||||
emby_route --> requireauth
|
||||
sab_route --> requireauth
|
||||
sonarr_route --> requireauth
|
||||
radarr_route --> requireauth
|
||||
dashboard --> requireauth
|
||||
entry --> poller : startPoller()
|
||||
|
||||
dashboard --> cache : read poll:* keys
|
||||
dashboard --> poller : pollAllServices()\n(on-demand mode)
|
||||
dashboard --> config : getSonarrInstances()\ngetRadarrInstances()
|
||||
dashboard --> qbt : mapTorrentToDownload()
|
||||
|
||||
poller --> cache : set poll:* keys
|
||||
poller --> config : get all instances
|
||||
poller --> qbt : getTorrents()
|
||||
poller --> logger
|
||||
|
||||
qbt --> config : getQbittorrentInstances()
|
||||
qbt --> logger
|
||||
}
|
||||
|
||||
cloud "External Services" as external {
|
||||
[Emby / Jellyfin] as emby
|
||||
[SABnzbd] as sab
|
||||
[Sonarr] as sonarr
|
||||
[Radarr] as radarr
|
||||
[qBittorrent] as qbit
|
||||
}
|
||||
|
||||
auth --> emby : authenticate\nuser profile
|
||||
dashboard --> emby : GET /Users\n(user-summary + tag badge classification)
|
||||
emby_route --> emby
|
||||
sab_route --> sab
|
||||
sonarr_route --> sonarr
|
||||
radarr_route --> radarr
|
||||
|
||||
poller --> sab : queue + history
|
||||
poller --> sonarr : tags + queue + history
|
||||
poller --> radarr : tags + queue + history
|
||||
qbt --> qbit : login + torrents/info
|
||||
|
||||
appjs --> auth : POST /login\nGET /me
|
||||
appjs --> dashboard : GET /user-downloads\nGET /status
|
||||
es --> html : serve static
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,67 @@
|
||||
@startuml seq-auth
|
||||
!theme plain
|
||||
title sofarr — Authentication Sequence
|
||||
|
||||
actor User as user
|
||||
participant "Browser\n(app.js)" as browser
|
||||
participant "Express\n/api/auth" as auth
|
||||
participant "Emby\nServer" as emby
|
||||
|
||||
== Page Load ==
|
||||
user -> browser : Navigate to sofarr
|
||||
activate browser
|
||||
browser -> auth : GET /api/auth/me
|
||||
activate auth
|
||||
auth -> auth : Read emby_user cookie
|
||||
alt Cookie exists and valid
|
||||
auth --> browser : { authenticated: true, user: { name, isAdmin } }
|
||||
browser -> browser : showDashboard()
|
||||
browser -> browser : fetchUserDownloads(true)
|
||||
browser -> browser : startAutoRefresh()
|
||||
browser -> browser : dismissSplash()
|
||||
else No cookie
|
||||
auth --> browser : { authenticated: false }
|
||||
browser -> browser : dismissSplash()
|
||||
browser -> browser : showLogin()
|
||||
end
|
||||
deactivate auth
|
||||
|
||||
== Login ==
|
||||
user -> browser : Enter username + password
|
||||
browser -> auth : POST /api/auth/login\n{ username, password }
|
||||
activate auth
|
||||
auth -> emby : POST /Users/authenticatebyname\n{ Username, Pw }
|
||||
activate emby
|
||||
alt Valid credentials
|
||||
emby --> auth : { User: { Id, ... }, AccessToken }
|
||||
auth -> emby : GET /Users/{userId}
|
||||
emby --> auth : { Name, Policy: { IsAdministrator } }
|
||||
deactivate emby
|
||||
auth -> auth : Set httpOnly cookie\nemby_user = { id, name, isAdmin }\n(24h TTL, secure in prod, sameSite=strict)\nNote: AccessToken NOT stored
|
||||
auth --> browser : { success: true, user: { name, isAdmin } }
|
||||
browser -> browser : fadeOutLogin()
|
||||
browser -> browser : showSplash()
|
||||
browser -> browser : showDashboard()
|
||||
browser -> browser : fetchUserDownloads(true)
|
||||
browser -> browser : startAutoRefresh()
|
||||
browser -> browser : dismissSplash()
|
||||
else Invalid credentials
|
||||
emby --> auth : 401 Error
|
||||
deactivate emby
|
||||
auth --> browser : { success: false, error: "Invalid..." }
|
||||
browser -> browser : showLoginError()
|
||||
end
|
||||
deactivate auth
|
||||
|
||||
== Logout ==
|
||||
user -> browser : Click Logout
|
||||
browser -> browser : stopAutoRefresh()
|
||||
browser -> auth : POST /api/auth/logout
|
||||
activate auth
|
||||
auth -> auth : Clear emby_user cookie
|
||||
auth --> browser : { success: true }
|
||||
deactivate auth
|
||||
browser -> browser : showLogin()
|
||||
|
||||
deactivate browser
|
||||
@enduml
|
||||
@@ -0,0 +1,100 @@
|
||||
@startuml seq-dashboard
|
||||
!theme plain
|
||||
title sofarr — Dashboard Request Sequence
|
||||
|
||||
actor User as user
|
||||
participant "Browser\n(app.js)" as browser
|
||||
participant "Express\n/api/dashboard" as dashboard
|
||||
participant "MemoryCache" as cache
|
||||
participant "Poller" as poller
|
||||
participant "External\nServices" as ext
|
||||
|
||||
== Periodic Refresh (or Initial Load) ==
|
||||
user -> browser : (auto-refresh fires)
|
||||
activate browser
|
||||
browser -> dashboard : GET /api/dashboard/user-downloads\n?refreshRate=5000&showAll=false
|
||||
activate dashboard
|
||||
|
||||
dashboard -> dashboard : Parse emby_user cookie\nExtract username, isAdmin
|
||||
dashboard -> dashboard : Track client refresh rate\nin activeClients Map
|
||||
|
||||
alt Polling disabled AND cache empty
|
||||
dashboard -> poller : pollAllServices()
|
||||
activate poller
|
||||
poller -> ext : Parallel API calls\n(SAB, Sonarr, Radarr, qBit)
|
||||
ext --> poller : Raw data
|
||||
poller -> cache : set poll:* keys\n(TTL = 30s)
|
||||
deactivate poller
|
||||
end
|
||||
|
||||
dashboard -> cache : get('poll:sab-queue')
|
||||
cache --> dashboard : { slots, status, speed }
|
||||
dashboard -> cache : get('poll:sab-history')
|
||||
cache --> dashboard : { slots }
|
||||
dashboard -> cache : get('poll:sonarr-tags')
|
||||
cache --> dashboard : [{ instance, data }]
|
||||
dashboard -> cache : get('poll:sonarr-queue')
|
||||
cache --> dashboard : { records } (with embedded series)
|
||||
dashboard -> cache : get('poll:sonarr-history')
|
||||
cache --> dashboard : { records }
|
||||
dashboard -> cache : get('poll:radarr-queue')
|
||||
cache --> dashboard : { records } (with embedded movie)
|
||||
dashboard -> cache : get('poll:radarr-history')
|
||||
cache --> dashboard : { records }
|
||||
dashboard -> cache : get('poll:radarr-tags')
|
||||
cache --> dashboard : [{id, label}]
|
||||
dashboard -> cache : get('poll:qbittorrent')
|
||||
cache --> dashboard : [torrent, ...]
|
||||
|
||||
dashboard -> dashboard : Build seriesMap from\nSonarr queue records
|
||||
dashboard -> dashboard : Build moviesMap from\nRadarr queue records
|
||||
dashboard -> dashboard : Build tag maps\n(id → label)
|
||||
|
||||
alt showAll=true
|
||||
dashboard -> cache : get('emby:users')
|
||||
alt cache miss
|
||||
dashboard -> ext : GET /Users (Emby)
|
||||
ext --> dashboard : [{ Name, ... }]
|
||||
dashboard -> cache : set('emby:users', map, 60s)
|
||||
end
|
||||
dashboard -> dashboard : Build embyUserMap\n(lowerName → displayName)
|
||||
end
|
||||
|
||||
group SABnzbd Queue Matching
|
||||
loop each queue slot
|
||||
dashboard -> dashboard : Match title vs Sonarr queue
|
||||
dashboard -> dashboard : Match title vs Radarr queue
|
||||
dashboard -> dashboard : extractAllTags() + extractUserTag(username)\nInclude if: showAll+anyTag OR matchedUserTag\nAttach allTags, matchedUserTag\nIf showAll: tagBadges = buildTagBadges(embyUserMap)
|
||||
end
|
||||
end
|
||||
|
||||
group SABnzbd History Matching
|
||||
loop each history slot
|
||||
dashboard -> dashboard : Match title vs Sonarr/Radarr history
|
||||
dashboard -> dashboard : Same tag extraction + inclusion logic
|
||||
end
|
||||
end
|
||||
|
||||
group qBittorrent Matching
|
||||
loop each torrent
|
||||
dashboard -> dashboard : 1. Match vs Sonarr queue
|
||||
dashboard -> dashboard : 2. Match vs Radarr queue
|
||||
dashboard -> dashboard : 3. Match vs Sonarr history
|
||||
dashboard -> dashboard : 4. Match vs Radarr history
|
||||
dashboard -> dashboard : mapTorrentToDownload()\n→ enrich: allTags, matchedUserTag, tagBadges
|
||||
end
|
||||
end
|
||||
|
||||
dashboard --> browser : { user, isAdmin,\ndownloads: [...] }
|
||||
deactivate dashboard
|
||||
|
||||
browser -> browser : renderDownloads() (diff-based)
|
||||
note right
|
||||
createDownloadCard() renders tag badges:
|
||||
- Normal: accent badge for matchedUserTag
|
||||
- showAll: amber badges (unmatched tags)
|
||||
accent badges (matched → show Emby displayName)
|
||||
end note
|
||||
deactivate browser
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,89 @@
|
||||
@startuml seq-polling
|
||||
!theme plain
|
||||
title sofarr — Background Polling Cycle
|
||||
|
||||
participant "index.js\n(startup)" as entry
|
||||
participant "Poller" as poller
|
||||
participant "Config" as config
|
||||
participant "SABnzbd\n(per instance)" as sab
|
||||
participant "Sonarr\n(per instance)" as sonarr
|
||||
participant "Radarr\n(per instance)" as radarr
|
||||
participant "qBittorrent\nClient" as qbt
|
||||
participant "MemoryCache" as cache
|
||||
|
||||
== Startup ==
|
||||
entry -> poller : startPoller()
|
||||
activate poller
|
||||
|
||||
alt POLL_INTERVAL > 0
|
||||
poller -> poller : pollAllServices() (immediate)
|
||||
poller -> poller : setInterval(pollAllServices,\nPOLL_INTERVAL)
|
||||
else POLL_INTERVAL = 0
|
||||
poller --> entry : "Polling disabled, on-demand mode"
|
||||
end
|
||||
|
||||
== Poll Cycle ==
|
||||
poller -> poller : Check: polling flag?\n(skip if concurrent)
|
||||
poller -> poller : polling = true
|
||||
poller -> poller : start = Date.now()
|
||||
|
||||
poller -> config : getSABnzbdInstances()
|
||||
config --> poller : [{ id, url, apiKey }]
|
||||
poller -> config : getSonarrInstances()
|
||||
config --> poller : [{ id, url, apiKey }]
|
||||
poller -> config : getRadarrInstances()
|
||||
config --> poller : [{ id, url, apiKey }]
|
||||
|
||||
note over poller : All fetches run in\nparallel via Promise.all,\neach wrapped in timed()
|
||||
|
||||
par SABnzbd Queue
|
||||
poller -> sab : GET /api?mode=queue
|
||||
sab --> poller : { queue: { slots, status, speed } }
|
||||
and SABnzbd History
|
||||
poller -> sab : GET /api?mode=history&limit=10
|
||||
sab --> poller : { history: { slots } }
|
||||
and Sonarr Tags
|
||||
poller -> sonarr : GET /api/v3/tag
|
||||
sonarr --> poller : [{ id, label }]
|
||||
and Sonarr Queue
|
||||
poller -> sonarr : GET /api/v3/queue\n?includeSeries=true
|
||||
sonarr --> poller : { records: [{ seriesId, series, ... }] }
|
||||
and Sonarr History
|
||||
poller -> sonarr : GET /api/v3/history\n?pageSize=10
|
||||
sonarr --> poller : { records: [{ seriesId, ... }] }
|
||||
and Radarr Queue
|
||||
poller -> radarr : GET /api/v3/queue\n?includeMovie=true
|
||||
radarr --> poller : { records: [{ movieId, movie, ... }] }
|
||||
and Radarr History
|
||||
poller -> radarr : GET /api/v3/history\n?pageSize=10
|
||||
radarr --> poller : { records: [{ movieId, ... }] }
|
||||
and Radarr Tags
|
||||
poller -> radarr : GET /api/v3/tag
|
||||
radarr --> poller : [{ id, label }]
|
||||
and qBittorrent
|
||||
poller -> qbt : getTorrents()
|
||||
qbt --> poller : [{ name, progress, ... }]
|
||||
end
|
||||
|
||||
poller -> poller : Record per-task timings\nlastPollTimings = { totalMs,\ntimestamp, tasks: [{label, ms}] }
|
||||
|
||||
poller -> poller : cacheTTL = POLL_INTERVAL × 3
|
||||
|
||||
poller -> cache : set('poll:sab-queue', ..., cacheTTL)
|
||||
poller -> cache : set('poll:sab-history', ..., cacheTTL)
|
||||
poller -> cache : set('poll:sonarr-tags', ..., cacheTTL)
|
||||
|
||||
note over poller : Tag queue records with\n_instanceUrl on embedded\nseries/movie objects
|
||||
|
||||
poller -> cache : set('poll:sonarr-queue', ..., cacheTTL)
|
||||
poller -> cache : set('poll:sonarr-history', ..., cacheTTL)
|
||||
poller -> cache : set('poll:radarr-queue', ..., cacheTTL)
|
||||
poller -> cache : set('poll:radarr-history', ..., cacheTTL)
|
||||
poller -> cache : set('poll:radarr-tags', ..., cacheTTL)
|
||||
poller -> cache : set('poll:qbittorrent', ..., cacheTTL)
|
||||
|
||||
poller -> poller : polling = false\nlog elapsed time
|
||||
|
||||
deactivate poller
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,65 @@
|
||||
@startuml state-poller
|
||||
!theme plain
|
||||
title sofarr — Poller State Diagram
|
||||
|
||||
[*] --> CheckConfig : startPoller()
|
||||
|
||||
state CheckConfig <<choice>>
|
||||
CheckConfig --> Disabled : POLL_INTERVAL = 0\nor 'off' / 'false'
|
||||
CheckConfig --> Idle : POLL_INTERVAL > 0
|
||||
|
||||
state Disabled {
|
||||
state "On-demand mode\nNo background timer" as od
|
||||
od : Data fetched only when\na dashboard request\nfinds empty cache
|
||||
}
|
||||
|
||||
Disabled --> Polling : pollAllServices()\n(triggered by dashboard request)
|
||||
Polling --> Disabled : Poll complete\n(return to on-demand)
|
||||
|
||||
state Idle {
|
||||
state "Waiting for\nnext interval" as waiting
|
||||
}
|
||||
|
||||
Idle --> Polling : setInterval fires\nor immediate first poll
|
||||
|
||||
state Polling {
|
||||
state "polling = true" as lock
|
||||
state "Fetching all services\n(Promise.all)" as fetching
|
||||
state "Storing results\nin cache" as storing
|
||||
state "Recording timings" as timing
|
||||
|
||||
[*] --> lock
|
||||
lock --> fetching
|
||||
fetching --> storing : All promises resolved
|
||||
fetching --> ErrorState : Any individual service\nerror (caught per-service)
|
||||
storing --> timing
|
||||
timing --> [*] : polling = false
|
||||
}
|
||||
|
||||
state ErrorState as "Handle Error" {
|
||||
state "Log error\npolling = false" as err
|
||||
}
|
||||
|
||||
ErrorState --> Idle : Next interval
|
||||
Polling --> Idle : Poll complete\n(back to waiting)
|
||||
|
||||
state "Concurrent Poll\nAttempt" as skip {
|
||||
state "polling === true\n→ skip" as sk
|
||||
}
|
||||
|
||||
Idle --> skip : Interval fires while\nprevious still running
|
||||
skip --> Idle : Log "still running,\nskipping"
|
||||
|
||||
note right of Polling
|
||||
**Cache TTL**: POLL_INTERVAL × 3
|
||||
Ensures data survives between polls
|
||||
even if one cycle is slow.
|
||||
end note
|
||||
|
||||
note right of Disabled
|
||||
**Cache TTL**: 30000ms (30s)
|
||||
After expiry, next dashboard
|
||||
request triggers a fresh poll.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -0,0 +1,79 @@
|
||||
@startuml state-ui
|
||||
!theme plain
|
||||
title sofarr — Frontend UI State Diagram
|
||||
|
||||
[*] --> SplashScreen : Page load
|
||||
|
||||
state SplashScreen {
|
||||
state "Showing splash\n(min 1.2s)" as showing
|
||||
}
|
||||
|
||||
SplashScreen --> CheckAuth : checkAuthentication()
|
||||
|
||||
state CheckAuth <<choice>>
|
||||
CheckAuth --> LoginForm : No session cookie
|
||||
CheckAuth --> Dashboard : Valid session
|
||||
|
||||
state LoginForm {
|
||||
state "Idle" as lf_idle
|
||||
state "Submitting" as lf_submit
|
||||
state "Error" as lf_error
|
||||
|
||||
lf_idle --> lf_submit : Submit form
|
||||
lf_submit --> lf_error : Auth failed
|
||||
lf_error --> lf_submit : Re-submit
|
||||
lf_submit --> FadeOutLogin : Auth success
|
||||
}
|
||||
|
||||
state FadeOutLogin {
|
||||
state "CSS transition\n(opacity → 0)" as fade
|
||||
}
|
||||
|
||||
FadeOutLogin --> SplashScreen2 : Show splash\nwhile loading
|
||||
|
||||
state SplashScreen2 as "Splash (loading data)" {
|
||||
state "fetchUserDownloads()" as fetching
|
||||
}
|
||||
|
||||
SplashScreen2 --> Dashboard : Data loaded\ndismissSplash()
|
||||
|
||||
state Dashboard {
|
||||
state "Rendering Cards" as rendering
|
||||
state "Auto Refreshing" as refreshing
|
||||
state "Status Panel Open" as status_open
|
||||
state "Status Panel Closed" as status_closed
|
||||
|
||||
[*] --> rendering
|
||||
rendering --> refreshing : startAutoRefresh()
|
||||
refreshing --> rendering : fetchUserDownloads()\n→ renderDownloads()
|
||||
rendering --> rendering : Theme change
|
||||
|
||||
status_closed --> status_open : Click "Status" btn\n(admin only)
|
||||
status_open --> status_closed : Click close (×)
|
||||
status_open --> status_open : Auto-refresh\nrenderStatusPanel()
|
||||
|
||||
[*] --> status_closed
|
||||
|
||||
state "Refresh Rate" as rr {
|
||||
state "1s" as r1
|
||||
state "5s (default)" as r5
|
||||
state "10s" as r10
|
||||
state "Off" as roff
|
||||
r5 --> r1 : User selects
|
||||
r5 --> r10
|
||||
r5 --> roff
|
||||
r1 --> r5
|
||||
r1 --> r10
|
||||
r1 --> roff
|
||||
r10 --> r1
|
||||
r10 --> r5
|
||||
r10 --> roff
|
||||
roff --> r1
|
||||
roff --> r5
|
||||
roff --> r10
|
||||
}
|
||||
}
|
||||
|
||||
Dashboard --> LoginForm : Logout\n(clear cookie,\nstopAutoRefresh)
|
||||
|
||||
@enduml
|
||||
Generated
+120
-133
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "media-download-dashboard",
|
||||
"version": "1.0.0",
|
||||
"name": "sofarr",
|
||||
"version": "0.1.4",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "media-download-dashboard",
|
||||
"version": "1.0.0",
|
||||
"name": "sofarr",
|
||||
"version": "0.1.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
@@ -14,11 +14,12 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"node-cron": "^3.0.3"
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"helmet": "^4.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^7.6.0",
|
||||
"nodemon": "^2.0.22"
|
||||
"nodemon": "^3.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
@@ -133,10 +134,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
@@ -174,13 +178,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
@@ -325,12 +331,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-7.6.0.tgz",
|
||||
@@ -623,6 +623,17 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.7.0.tgz",
|
||||
"integrity": "sha512-vhwIdRoqcYB/72TK3tRZI+0ttS8Ytrk24GfmsxDXK9o9IhHNO5bXRiXQSExPQ4GbaE5tvIS7j1SGrxsuWs+sGA==",
|
||||
"engines": {
|
||||
"node": ">= 12.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": "^4 || ^5"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -836,6 +847,14 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-4.6.0.tgz",
|
||||
"integrity": "sha512-HVqALKZlR95ROkrnesdhbbZJFi/rIVSoNq6f3jA/9u6MIbTsPh3xZwihjeI5+DO/2sOV6HMHooXcEOuwskHpTg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -1038,15 +1057,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
@@ -1062,30 +1084,19 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
|
||||
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
|
||||
"dependencies": {
|
||||
"uuid": "8.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "2.0.22",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz",
|
||||
"integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==",
|
||||
"version": "3.1.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
||||
"integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^3.5.2",
|
||||
"debug": "^3.2.7",
|
||||
"debug": "^4",
|
||||
"ignore-by-default": "^1.0.1",
|
||||
"minimatch": "^3.1.2",
|
||||
"minimatch": "^10.2.1",
|
||||
"pstree.remy": "^1.1.8",
|
||||
"semver": "^5.7.1",
|
||||
"simple-update-notifier": "^1.0.7",
|
||||
"semver": "^7.5.3",
|
||||
"simple-update-notifier": "^2.0.0",
|
||||
"supports-color": "^5.5.0",
|
||||
"touch": "^3.1.0",
|
||||
"undefsafe": "^2.0.5"
|
||||
@@ -1094,7 +1105,7 @@
|
||||
"nodemon": "bin/nodemon.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -1102,12 +1113,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.1"
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/has-flag": {
|
||||
@@ -1318,12 +1337,15 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
|
||||
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver"
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
@@ -1454,24 +1476,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz",
|
||||
"integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"semver": "~7.0.0"
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier/node_modules/semver": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz",
|
||||
"integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/spawn-command": {
|
||||
@@ -1607,15 +1620,6 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
@@ -1764,9 +1768,9 @@
|
||||
}
|
||||
},
|
||||
"balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true
|
||||
},
|
||||
"binary-extensions": {
|
||||
@@ -1795,13 +1799,12 @@
|
||||
}
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
"balanced-match": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"braces": {
|
||||
@@ -1907,12 +1910,6 @@
|
||||
"delayed-stream": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true
|
||||
},
|
||||
"concurrently": {
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-7.6.0.tgz",
|
||||
@@ -2124,6 +2121,12 @@
|
||||
"vary": "~1.1.2"
|
||||
}
|
||||
},
|
||||
"express-rate-limit": {
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.7.0.tgz",
|
||||
"integrity": "sha512-vhwIdRoqcYB/72TK3tRZI+0ttS8Ytrk24GfmsxDXK9o9IhHNO5bXRiXQSExPQ4GbaE5tvIS7j1SGrxsuWs+sGA==",
|
||||
"requires": {}
|
||||
},
|
||||
"fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -2259,6 +2262,11 @@
|
||||
"function-bind": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"helmet": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-4.6.0.tgz",
|
||||
"integrity": "sha512-HVqALKZlR95ROkrnesdhbbZJFi/rIVSoNq6f3jA/9u6MIbTsPh3xZwihjeI5+DO/2sOV6HMHooXcEOuwskHpTg=="
|
||||
},
|
||||
"http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -2400,12 +2408,12 @@
|
||||
}
|
||||
},
|
||||
"minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
"brace-expansion": "^5.0.5"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
@@ -2418,39 +2426,31 @@
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="
|
||||
},
|
||||
"node-cron": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
|
||||
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
|
||||
"requires": {
|
||||
"uuid": "8.3.2"
|
||||
}
|
||||
},
|
||||
"nodemon": {
|
||||
"version": "2.0.22",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz",
|
||||
"integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==",
|
||||
"version": "3.1.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz",
|
||||
"integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"chokidar": "^3.5.2",
|
||||
"debug": "^3.2.7",
|
||||
"debug": "^4",
|
||||
"ignore-by-default": "^1.0.1",
|
||||
"minimatch": "^3.1.2",
|
||||
"minimatch": "^10.2.1",
|
||||
"pstree.remy": "^1.1.8",
|
||||
"semver": "^5.7.1",
|
||||
"simple-update-notifier": "^1.0.7",
|
||||
"semver": "^7.5.3",
|
||||
"simple-update-notifier": "^2.0.0",
|
||||
"supports-color": "^5.5.0",
|
||||
"touch": "^3.1.0",
|
||||
"undefsafe": "^2.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ms": "^2.1.1"
|
||||
"ms": "^2.1.3"
|
||||
}
|
||||
},
|
||||
"has-flag": {
|
||||
@@ -2595,9 +2595,9 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"semver": {
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
|
||||
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"dev": true
|
||||
},
|
||||
"send": {
|
||||
@@ -2694,20 +2694,12 @@
|
||||
}
|
||||
},
|
||||
"simple-update-notifier": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz",
|
||||
"integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"semver": "~7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz",
|
||||
"integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==",
|
||||
"dev": true
|
||||
}
|
||||
"semver": "^7.5.3"
|
||||
}
|
||||
},
|
||||
"spawn-command": {
|
||||
@@ -2807,11 +2799,6 @@
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="
|
||||
},
|
||||
"uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
|
||||
},
|
||||
"vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
||||
+11
-8
@@ -1,24 +1,27 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.5",
|
||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon server/index.js",
|
||||
"start": "node server/index.js",
|
||||
"install:all": "npm install"
|
||||
"install:all": "npm install",
|
||||
"audit": "npm audit --audit-level=moderate",
|
||||
"audit:fix": "npm audit fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"axios": "^1.6.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"axios": "^1.6.0",
|
||||
"node-cron": "^3.0.3",
|
||||
"cookie-parser": "^1.4.6"
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"helmet": "^4.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.22",
|
||||
"concurrently": "^7.6.0"
|
||||
"concurrently": "^7.6.0",
|
||||
"nodemon": "^3.1.14"
|
||||
},
|
||||
"keywords": [
|
||||
"sabnzbd",
|
||||
|
||||
+273
-11
@@ -4,6 +4,7 @@ let refreshInterval = null;
|
||||
let currentRefreshRate = 5000; // default 5 seconds
|
||||
let isAdmin = false;
|
||||
let showAll = false;
|
||||
const SPLASH_MIN_MS = 1200; // minimum splash display time
|
||||
|
||||
// Apply saved theme immediately (before DOMContentLoaded to avoid flash)
|
||||
(function() {
|
||||
@@ -20,6 +21,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
||||
document.getElementById('refresh-rate').addEventListener('change', handleRefreshRateChange);
|
||||
document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle);
|
||||
document.getElementById('status-btn').addEventListener('click', toggleStatusPanel);
|
||||
});
|
||||
|
||||
function initThemeSwitcher() {
|
||||
@@ -49,6 +51,14 @@ function handleRefreshRateChange(e) {
|
||||
const rate = parseInt(e.target.value);
|
||||
currentRefreshRate = rate;
|
||||
startAutoRefresh();
|
||||
// Restart status panel refresh if it's open
|
||||
const statusPanel = document.getElementById('status-panel');
|
||||
if (statusPanel && statusPanel.style.display !== 'none') {
|
||||
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
||||
if (currentRefreshRate > 0) {
|
||||
statusRefreshHandle = setInterval(refreshStatusPanel, currentRefreshRate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleShowAllToggle(e) {
|
||||
@@ -63,7 +73,50 @@ function stopAutoRefresh() {
|
||||
}
|
||||
}
|
||||
|
||||
function fadeOutLogin() {
|
||||
return new Promise(resolve => {
|
||||
const login = document.getElementById('login-container');
|
||||
login.classList.add('fade-out');
|
||||
login.addEventListener('transitionend', () => {
|
||||
login.style.display = 'none';
|
||||
login.classList.remove('fade-out');
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function showSplash() {
|
||||
const splash = document.getElementById('splash-screen');
|
||||
splash.style.display = 'flex';
|
||||
splash.style.opacity = '1';
|
||||
splash.classList.remove('fade-out');
|
||||
}
|
||||
|
||||
function dismissSplash(startTime) {
|
||||
return new Promise(resolve => {
|
||||
const elapsed = Date.now() - (startTime || 0);
|
||||
const remaining = Math.max(0, SPLASH_MIN_MS - elapsed);
|
||||
setTimeout(() => {
|
||||
const splash = document.getElementById('splash-screen');
|
||||
splash.classList.add('fade-out');
|
||||
// Fallback: resolve after transition duration + buffer in case
|
||||
// transitionend never fires (e.g. display was toggled in same frame)
|
||||
const TRANSITION_MS = 400;
|
||||
const fallback = setTimeout(() => {
|
||||
splash.style.display = 'none';
|
||||
resolve();
|
||||
}, TRANSITION_MS + 100);
|
||||
splash.addEventListener('transitionend', () => {
|
||||
clearTimeout(fallback);
|
||||
splash.style.display = 'none';
|
||||
resolve();
|
||||
}, { once: true });
|
||||
}, remaining);
|
||||
});
|
||||
}
|
||||
|
||||
async function checkAuthentication() {
|
||||
const splashStart = Date.now();
|
||||
try {
|
||||
const response = await fetch('/api/auth/me');
|
||||
const data = await response.json();
|
||||
@@ -72,13 +125,16 @@ async function checkAuthentication() {
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.user.isAdmin;
|
||||
showDashboard();
|
||||
fetchUserDownloads(true);
|
||||
await fetchUserDownloads(true);
|
||||
startAutoRefresh();
|
||||
await dismissSplash(splashStart);
|
||||
} else {
|
||||
await dismissSplash(splashStart);
|
||||
showLogin();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Authentication check failed:', err);
|
||||
await dismissSplash(splashStart);
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
@@ -88,6 +144,7 @@ async function handleLogin(e) {
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const rememberMe = document.getElementById('remember-me').checked;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
@@ -95,7 +152,7 @@ async function handleLogin(e) {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
body: JSON.stringify({ username, password, rememberMe })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -103,9 +160,18 @@ async function handleLogin(e) {
|
||||
if (data.success) {
|
||||
currentUser = data.user;
|
||||
isAdmin = !!data.user.isAdmin;
|
||||
// Fade out login, then show splash while loading data.
|
||||
// requestAnimationFrame ensures the browser paints the splash at
|
||||
// opacity:1 before dismissSplash adds fade-out, so the CSS
|
||||
// transition fires and transitionend is guaranteed.
|
||||
await fadeOutLogin();
|
||||
showSplash();
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
showDashboard();
|
||||
fetchUserDownloads(true);
|
||||
const splashStart = Date.now();
|
||||
await fetchUserDownloads(true);
|
||||
startAutoRefresh();
|
||||
await dismissSplash(splashStart);
|
||||
} else {
|
||||
showLoginError(data.error || 'Login failed');
|
||||
}
|
||||
@@ -160,7 +226,10 @@ async function fetchUserDownloads(isInitialLoad = false) {
|
||||
hideError();
|
||||
|
||||
try {
|
||||
const url = showAll ? '/api/dashboard/user-downloads?showAll=true' : '/api/dashboard/user-downloads';
|
||||
const params = new URLSearchParams();
|
||||
if (showAll) params.set('showAll', 'true');
|
||||
params.set('refreshRate', currentRefreshRate);
|
||||
const url = '/api/dashboard/user-downloads?' + params.toString();
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
@@ -340,6 +409,14 @@ function createDownloadCard(download) {
|
||||
|
||||
header.appendChild(type);
|
||||
header.appendChild(status);
|
||||
|
||||
if (download.importIssues && download.importIssues.length > 0) {
|
||||
const issueBadge = document.createElement('span');
|
||||
issueBadge.className = 'import-issue-badge';
|
||||
issueBadge.textContent = 'Import Pending';
|
||||
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
|
||||
header.appendChild(issueBadge);
|
||||
}
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'download-title';
|
||||
@@ -351,22 +428,49 @@ function createDownloadCard(download) {
|
||||
if (download.seriesName) {
|
||||
const series = document.createElement('p');
|
||||
series.className = 'download-series';
|
||||
series.textContent = `Series: ${download.seriesName}`;
|
||||
if (isAdmin && download.arrLink) {
|
||||
series.innerHTML = 'Series: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.seriesName) + '</a>';
|
||||
} else {
|
||||
series.textContent = `Series: ${download.seriesName}`;
|
||||
}
|
||||
infoDiv.appendChild(series);
|
||||
}
|
||||
|
||||
if (download.movieName) {
|
||||
const movie = document.createElement('p');
|
||||
movie.className = 'download-movie';
|
||||
movie.textContent = `Movie: ${download.movieName}`;
|
||||
if (isAdmin && download.arrLink) {
|
||||
movie.innerHTML = 'Movie: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.movieName) + '</a>';
|
||||
} else {
|
||||
movie.textContent = `Movie: ${download.movieName}`;
|
||||
}
|
||||
infoDiv.appendChild(movie);
|
||||
}
|
||||
|
||||
if (showAll && download.userTag) {
|
||||
const userBadge = document.createElement('span');
|
||||
userBadge.className = 'download-user-badge';
|
||||
userBadge.textContent = download.userTag;
|
||||
header.appendChild(userBadge);
|
||||
if (showAll && download.tagBadges && download.tagBadges.length > 0) {
|
||||
// In showAll mode: render all tags classified by whether they match an Emby user.
|
||||
// Unmatched (no known Emby user) → amber, leftmost.
|
||||
// Matched → show Emby display name in accent colour, rightmost.
|
||||
const unmatched = download.tagBadges.filter(b => !b.matchedUser);
|
||||
const matched = download.tagBadges.filter(b => b.matchedUser);
|
||||
for (const b of unmatched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge unmatched';
|
||||
badge.textContent = b.label;
|
||||
header.appendChild(badge);
|
||||
}
|
||||
for (const b of matched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge';
|
||||
badge.textContent = b.matchedUser;
|
||||
header.appendChild(badge);
|
||||
}
|
||||
} else if (download.matchedUserTag) {
|
||||
// Normal (non-showAll) view: show only the current user's matched tag
|
||||
const matchedBadge = document.createElement('span');
|
||||
matchedBadge.className = 'download-user-badge';
|
||||
matchedBadge.textContent = download.matchedUserTag;
|
||||
header.appendChild(matchedBadge);
|
||||
}
|
||||
|
||||
const details = document.createElement('div');
|
||||
@@ -458,6 +562,24 @@ function createDownloadCard(download) {
|
||||
const completed = createDetailItem('Completed', formatDate(download.completedAt));
|
||||
details.appendChild(completed);
|
||||
}
|
||||
|
||||
if (isAdmin && (download.downloadPath || download.targetPath)) {
|
||||
const pathsDiv = document.createElement('div');
|
||||
pathsDiv.className = 'download-paths';
|
||||
if (download.downloadPath) {
|
||||
const dlPath = document.createElement('div');
|
||||
dlPath.className = 'path-item';
|
||||
dlPath.innerHTML = '<span class="path-label">Download:</span> <span class="path-value">' + escapeHtml(download.downloadPath) + '</span>';
|
||||
pathsDiv.appendChild(dlPath);
|
||||
}
|
||||
if (download.targetPath) {
|
||||
const tgtPath = document.createElement('div');
|
||||
tgtPath.className = 'path-item';
|
||||
tgtPath.innerHTML = '<span class="path-label">Target:</span> <span class="path-value">' + escapeHtml(download.targetPath) + '</span>';
|
||||
pathsDiv.appendChild(tgtPath);
|
||||
}
|
||||
details.appendChild(pathsDiv);
|
||||
}
|
||||
|
||||
infoDiv.appendChild(details);
|
||||
card.appendChild(infoDiv);
|
||||
@@ -484,6 +606,146 @@ function createDetailItem(label, value) {
|
||||
return item;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
let statusRefreshHandle = null;
|
||||
|
||||
async function toggleStatusPanel() {
|
||||
const panel = document.getElementById('status-panel');
|
||||
if (panel.style.display !== 'none') {
|
||||
panel.style.display = 'none';
|
||||
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
||||
return;
|
||||
}
|
||||
panel.style.display = 'block';
|
||||
await refreshStatusPanel();
|
||||
// Auto-refresh in sync with dashboard refresh rate
|
||||
if (statusRefreshHandle) clearInterval(statusRefreshHandle);
|
||||
if (currentRefreshRate > 0) {
|
||||
statusRefreshHandle = setInterval(refreshStatusPanel, currentRefreshRate);
|
||||
}
|
||||
}
|
||||
|
||||
function closeStatusPanel() {
|
||||
document.getElementById('status-panel').style.display = 'none';
|
||||
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
||||
}
|
||||
|
||||
async function refreshStatusPanel() {
|
||||
const panel = document.getElementById('status-panel');
|
||||
if (!panel || panel.style.display === 'none') return;
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/status');
|
||||
if (!res.ok) throw new Error('Failed to fetch status');
|
||||
const data = await res.json();
|
||||
renderStatusPanel(data, panel);
|
||||
} catch (err) {
|
||||
// Don't overwrite panel on transient error during auto-refresh
|
||||
if (!panel.innerHTML || panel.innerHTML.includes('status-loading')) {
|
||||
panel.innerHTML = '<p class="status-error">Failed to load status.</p>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatusPanel(data, panel) {
|
||||
const s = data.server;
|
||||
const hrs = Math.floor(s.uptimeSeconds / 3600);
|
||||
const mins = Math.floor((s.uptimeSeconds % 3600) / 60);
|
||||
const secs = s.uptimeSeconds % 60;
|
||||
const uptime = `${hrs}h ${mins}m ${secs}s`;
|
||||
|
||||
const totalKB = (data.cache.totalSizeBytes / 1024).toFixed(1);
|
||||
|
||||
let html = `
|
||||
<div class="status-header">
|
||||
<h3>Server Status</h3>
|
||||
<button class="status-close" onclick="closeStatusPanel()">×</button>
|
||||
</div>
|
||||
<div class="status-grid">
|
||||
<div class="status-card">
|
||||
<div class="status-card-title">Server</div>
|
||||
<div class="status-row"><span>Uptime</span><span>${uptime}</span></div>
|
||||
<div class="status-row"><span>Node</span><span>${escapeHtml(s.nodeVersion)}</span></div>
|
||||
<div class="status-row"><span>Memory (RSS)</span><span>${s.memoryUsageMB} MB</span></div>
|
||||
<div class="status-row"><span>Heap</span><span>${s.heapUsedMB} / ${s.heapTotalMB} MB</span></div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-card-title">Data Refresh</div>`;
|
||||
|
||||
const pollIntervalMs = data.polling.intervalMs;
|
||||
const clients = data.clients || [];
|
||||
const activeRefreshers = clients.filter(c => c.refreshRateMs > 0);
|
||||
const fastestClient = activeRefreshers.length > 0
|
||||
? activeRefreshers.reduce((min, c) => c.refreshRateMs < min.refreshRateMs ? c : min)
|
||||
: null;
|
||||
const hasForegroundClient = fastestClient && data.polling.enabled && fastestClient.refreshRateMs < pollIntervalMs;
|
||||
|
||||
if (data.polling.enabled) {
|
||||
html += `<div class="status-row"><span>Background poll</span><span>${pollIntervalMs / 1000}s</span></div>`;
|
||||
} else {
|
||||
html += `<div class="status-row"><span>Background poll</span><span>Disabled</span></div>`;
|
||||
}
|
||||
|
||||
if (hasForegroundClient) {
|
||||
html += `<div class="status-row"><span>Effective mode</span><span class="status-fg-badge">Foreground ${fastestClient.refreshRateMs / 1000}s</span></div>`;
|
||||
} else if (activeRefreshers.length > 0) {
|
||||
html += `<div class="status-row"><span>Effective mode</span><span>${data.polling.enabled ? 'Background' : 'On-demand'}</span></div>`;
|
||||
} else {
|
||||
html += `<div class="status-row"><span>Effective mode</span><span>Idle (no active clients)</span></div>`;
|
||||
}
|
||||
|
||||
html += `<div class="status-row"><span>Active clients</span><span>${clients.length}</span></div>`;
|
||||
for (const c of clients) {
|
||||
const rate = c.refreshRateMs > 0 ? (c.refreshRateMs / 1000) + 's' : 'Off';
|
||||
const age = Math.round((Date.now() - c.lastSeen) / 1000);
|
||||
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>${rate} (${age}s ago)</span></div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
// Poll timings card
|
||||
const lp = data.polling.lastPoll;
|
||||
if (lp) {
|
||||
const pollAge = Math.round((Date.now() - new Date(lp.timestamp).getTime()) / 1000);
|
||||
html += `
|
||||
<div class="status-card status-card-wide">
|
||||
<div class="status-card-title">Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)</div>
|
||||
<div class="status-timings">`;
|
||||
for (const t of lp.tasks) {
|
||||
const barWidth = lp.totalMs > 0 ? Math.max(2, (t.ms / lp.totalMs) * 100) : 0;
|
||||
html += `
|
||||
<div class="timing-row">
|
||||
<span class="timing-label">${escapeHtml(t.label)}</span>
|
||||
<div class="timing-bar-bg"><div class="timing-bar" style="width:${barWidth.toFixed(1)}%"></div></div>
|
||||
<span class="timing-value">${t.ms}ms</span>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
// Cache table
|
||||
html += `
|
||||
<div class="status-card status-card-wide">
|
||||
<div class="status-card-title">Cache (${data.cache.entryCount} entries, ${totalKB} KB)</div>
|
||||
<table class="status-table">
|
||||
<thead><tr><th>Key</th><th>Items</th><th>Size</th><th>TTL</th></tr></thead>
|
||||
<tbody>`;
|
||||
|
||||
for (const e of data.cache.entries) {
|
||||
const sizeStr = e.sizeBytes > 1024 ? (e.sizeBytes / 1024).toFixed(1) + ' KB' : e.sizeBytes + ' B';
|
||||
const ttlStr = e.expired ? '<span class="status-expired">expired</span>' : (e.ttlRemainingMs / 1000).toFixed(0) + 's';
|
||||
const items = e.itemCount !== null ? e.itemCount : '—';
|
||||
html += `<tr><td><code>${escapeHtml(e.key)}</code></td><td>${items}</td><td>${sizeStr}</td><td>${ttlStr}</td></tr>`;
|
||||
}
|
||||
|
||||
html += `</tbody></table></div></div>`;
|
||||
panel.innerHTML = html;
|
||||
}
|
||||
|
||||
function formatSize(size) {
|
||||
if (!size) return 'N/A';
|
||||
// If already a formatted string (e.g., "21.5 GB"), return as-is
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 543 B |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
+20
-1
@@ -4,14 +4,24 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>sofarr - Your Downloads Dashboard</title>
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
|
||||
<meta name="theme-color" content="#1a1a2e">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Splash Screen -->
|
||||
<div id="splash-screen" class="splash-screen">
|
||||
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="splash-logo">
|
||||
</div>
|
||||
|
||||
<div class="app">
|
||||
<!-- Login Form -->
|
||||
<div id="login-container" class="login-container" style="display: none;">
|
||||
<div class="login-box">
|
||||
<h2>Login to Emby</h2>
|
||||
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="login-logo">
|
||||
<p class="login-subtitle">Login with your Emby credentials</p>
|
||||
<form id="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Username:</label>
|
||||
@@ -21,6 +31,12 @@
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="form-group form-group--checkbox">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="remember-me" name="rememberMe">
|
||||
<span>Keep me logged in</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="login-btn">Login</button>
|
||||
</form>
|
||||
<div id="login-error" class="error-message" style="display: none;"></div>
|
||||
@@ -51,6 +67,7 @@
|
||||
<input type="checkbox" id="show-all-toggle">
|
||||
<span>Show all users</span>
|
||||
</label>
|
||||
<button id="status-btn" class="status-btn">Status</button>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span class="user-label">Current User:</span>
|
||||
@@ -60,6 +77,8 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="status-panel" class="status-panel" style="display: none;"></div>
|
||||
|
||||
<div id="error-message" class="error-message" style="display: none;"></div>
|
||||
|
||||
<div id="loading" class="loading" style="display: none;">Loading downloads...</div>
|
||||
|
||||
+456
-9
@@ -1,3 +1,34 @@
|
||||
/* ===== Splash Screen ===== */
|
||||
.splash-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f8f9fa;
|
||||
z-index: 9999;
|
||||
opacity: 1;
|
||||
transition: opacity 0.4s ease-out;
|
||||
}
|
||||
|
||||
.splash-screen.fade-out {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.splash-logo {
|
||||
max-width: 280px;
|
||||
width: 60%;
|
||||
animation: splashPulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes splashPulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.03); opacity: 0.85; }
|
||||
}
|
||||
|
||||
/* ===== Theme Variables ===== */
|
||||
:root, [data-theme="light"] {
|
||||
--bg-gradient-start: #667eea;
|
||||
@@ -33,6 +64,8 @@
|
||||
--footer-text: rgba(255, 255, 255, 0.9);
|
||||
--input-bg: #ffffff;
|
||||
--select-bg: #ffffff;
|
||||
--unmatched-tag-bg: #fff3e0;
|
||||
--unmatched-tag-color: #e65100;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
@@ -69,6 +102,8 @@
|
||||
--footer-text: rgba(200, 200, 220, 0.8);
|
||||
--input-bg: #2a2a3d;
|
||||
--select-bg: #2a2a3d;
|
||||
--unmatched-tag-bg: #3d2a00;
|
||||
--unmatched-tag-color: #ffb74d;
|
||||
}
|
||||
|
||||
[data-theme="mono"] {
|
||||
@@ -105,6 +140,8 @@
|
||||
--footer-text: rgba(180, 180, 180, 0.7);
|
||||
--input-bg: #252525;
|
||||
--select-bg: #252525;
|
||||
--unmatched-tag-bg: #2a2a2a;
|
||||
--unmatched-tag-color: #a0a0a0;
|
||||
}
|
||||
|
||||
/* ===== Base ===== */
|
||||
@@ -347,8 +384,9 @@ body {
|
||||
|
||||
.download-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
@@ -407,9 +445,9 @@ body {
|
||||
margin-bottom: 2px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.download-series,
|
||||
@@ -492,7 +530,8 @@ body {
|
||||
color: var(--danger);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ===== Footer ===== */
|
||||
@@ -514,6 +553,12 @@ body {
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
.login-container.fade-out {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
@@ -524,17 +569,24 @@ body {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
transition: background 0.3s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-box h2 {
|
||||
color: var(--text-primary);
|
||||
.login-logo {
|
||||
max-width: 180px;
|
||||
width: 60%;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
@@ -560,6 +612,32 @@ body {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-group--checkbox {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox-label span {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
@@ -602,6 +680,80 @@ body {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===== Arr Links (Admin) ===== */
|
||||
.arr-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--accent);
|
||||
}
|
||||
|
||||
.arr-link:hover {
|
||||
opacity: 0.8;
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
|
||||
/* ===== Download Paths (Admin) ===== */
|
||||
.download-paths {
|
||||
flex-basis: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.path-item {
|
||||
font-size: 0.7rem;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace;
|
||||
color: var(--text-muted);
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.path-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.path-value {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ===== Import Issue Badge ===== */
|
||||
.import-issue-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
cursor: help;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.import-issue-badge:hover::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
background: #424242;
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
white-space: pre-line;
|
||||
max-width: 320px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.download-user-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
@@ -611,21 +763,256 @@ body {
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.download-user-badge.unmatched {
|
||||
background: var(--unmatched-tag-bg);
|
||||
color: var(--unmatched-tag-color);
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* ===== Status Button ===== */
|
||||
.status-btn {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.status-btn:hover {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===== Status Panel ===== */
|
||||
.status-panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 4px var(--shadow);
|
||||
}
|
||||
|
||||
.status-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-header h3 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.status-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.4rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.status-card-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.status-card-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 3px 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-row span:last-child {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.status-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.status-table th {
|
||||
text-align: left;
|
||||
padding: 4px 8px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.status-table td {
|
||||
padding: 5px 8px;
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.status-table code {
|
||||
font-size: 0.75rem;
|
||||
background: var(--surface);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.status-expired {
|
||||
color: #c62828;
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.status-fg-badge {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
padding: 1px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-row-sub {
|
||||
padding-left: 12px;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.status-row-sub span:first-child {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.status-timings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.timing-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.timing-label {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timing-bar-bg {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timing-bar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 4px;
|
||||
min-width: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.timing-value {
|
||||
width: 50px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.status-loading, .status-error {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
/* ===== Mobile ===== */
|
||||
@media (max-width: 768px) {
|
||||
.app {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 12px 14px;
|
||||
padding: 10px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.admin-controls {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.downloads-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.download-card {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
@@ -642,4 +1029,64 @@ body {
|
||||
.progress-container {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-table {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.status-table th,
|
||||
.status-table td {
|
||||
padding: 4px 4px;
|
||||
}
|
||||
|
||||
.status-table td code {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.timing-label {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.timing-value {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.import-issue-badge:hover::after {
|
||||
left: auto;
|
||||
right: 0;
|
||||
max-width: calc(100vw - 24px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Very small screens (≤ 400px) ===== */
|
||||
@media (max-width: 400px) {
|
||||
.app {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.download-cover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-switcher {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
font-size: 0.78rem;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.download-card {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.timing-label {
|
||||
width: 75px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
+16
-3
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const helmet = require('helmet');
|
||||
const fs = require('fs');
|
||||
require('dotenv').config();
|
||||
|
||||
@@ -54,12 +54,23 @@ const radarrRoutes = require('./routes/radarr');
|
||||
const embyRoutes = require('./routes/emby');
|
||||
const dashboardRoutes = require('./routes/dashboard');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(cors());
|
||||
app.use(cookieParser());
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false // SPA uses inline scripts; CSP requires a nonce/hash strategy
|
||||
}));
|
||||
|
||||
const cookieSecret = process.env.COOKIE_SECRET;
|
||||
if (!cookieSecret && process.env.NODE_ENV === 'production') {
|
||||
console.error('[Security] COOKIE_SECRET is not set in production — cookies are unsigned and can be tampered with!');
|
||||
process.exit(1);
|
||||
} else if (!cookieSecret) {
|
||||
console.warn('[Security] COOKIE_SECRET is not set — using unsigned cookies (acceptable for development only)');
|
||||
}
|
||||
app.use(cookieParser(cookieSecret || undefined));
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
@@ -79,5 +90,7 @@ app.listen(PORT, () => {
|
||||
console.log(` sofarr - Your Downloads Dashboard`);
|
||||
console.log(` Server running on port ${PORT}`);
|
||||
console.log(` Log level: ${process.env.LOG_LEVEL || 'info'}`);
|
||||
console.log(` Polling: ${POLLING_ENABLED ? POLL_INTERVAL + 'ms' : 'disabled (on-demand)'}`);
|
||||
console.log(`=================================`);
|
||||
startPoller();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
function requireAuth(req, res, next) {
|
||||
const signed = !!process.env.COOKIE_SECRET;
|
||||
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
|
||||
if (!raw || raw === false) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
let u;
|
||||
try {
|
||||
u = JSON.parse(raw);
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid session' });
|
||||
}
|
||||
// Schema validation
|
||||
if (typeof u.id !== 'string' || !u.id) return res.status(401).json({ error: 'Invalid session' });
|
||||
if (typeof u.name !== 'string' || !u.name) return res.status(401).json({ error: 'Invalid session' });
|
||||
if (typeof u.isAdmin !== 'boolean') u.isAdmin = !!u.isAdmin;
|
||||
req.user = u;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = requireAuth;
|
||||
+98
-45
@@ -1,29 +1,57 @@
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const crypto = require('crypto');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
|
||||
const EMBY_URL = process.env.EMBY_URL;
|
||||
const EMBY_API_KEY = process.env.EMBY_API_KEY;
|
||||
|
||||
// Server-side token store: userId -> { accessToken }
|
||||
// Keeps AccessToken off the client; required for logout revocation.
|
||||
const tokenStore = new Map();
|
||||
|
||||
function storeToken(userId, accessToken) {
|
||||
tokenStore.set(userId, { accessToken });
|
||||
}
|
||||
|
||||
function getToken(userId) {
|
||||
return tokenStore.get(userId) || null;
|
||||
}
|
||||
|
||||
function clearToken(userId) {
|
||||
tokenStore.delete(userId);
|
||||
}
|
||||
|
||||
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { success: false, error: 'Too many login attempts, please try again later' }
|
||||
});
|
||||
|
||||
// Authenticate user with Emby
|
||||
router.post('/login', async (req, res) => {
|
||||
router.post('/login', loginLimiter, async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
const { username, password, rememberMe } = req.body;
|
||||
|
||||
console.log(`[Auth] Attempting login for user: ${username}`);
|
||||
|
||||
// Authenticate with Emby
|
||||
// Authenticate with Emby using a stable DeviceId derived from the username.
|
||||
// Using a deterministic DeviceId causes Emby to reuse the existing session
|
||||
// for this device rather than creating a new one on each login.
|
||||
const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16);
|
||||
const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, {
|
||||
Username: username,
|
||||
Pw: password
|
||||
}, {
|
||||
headers: {
|
||||
'X-Emby-Authorization': `MediaBrowser Client="MediaDashboard", Device="Browser", DeviceId="dashboard-${Date.now()}", Version="1.0.0"`
|
||||
'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"`
|
||||
}
|
||||
});
|
||||
|
||||
const authData = authResponse.data;
|
||||
console.log(`[Auth] Emby auth response:`, JSON.stringify(authData));
|
||||
|
||||
// Get user info using the access token
|
||||
const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, {
|
||||
@@ -33,28 +61,31 @@ router.post('/login', async (req, res) => {
|
||||
});
|
||||
|
||||
const user = userResponse.data;
|
||||
console.log(`[Auth] User info:`, JSON.stringify(user));
|
||||
console.log(`[Auth] Login successful for user: ${user.Name}`);
|
||||
|
||||
// Set authentication cookie
|
||||
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
|
||||
res.cookie('emby_user', JSON.stringify({
|
||||
id: user.Id,
|
||||
name: user.Name,
|
||||
isAdmin: isAdmin,
|
||||
token: authData.AccessToken
|
||||
}), {
|
||||
console.log(`[Auth] Login successful for user: ${user.Name}, isAdmin: ${isAdmin}`);
|
||||
|
||||
// Store token server-side; it is never sent to the client.
|
||||
storeToken(user.Id, authData.AccessToken);
|
||||
|
||||
// Set authentication cookie (signed when COOKIE_SECRET is set).
|
||||
// rememberMe=true → persistent cookie, expires in 30 days
|
||||
// rememberMe=false → session cookie, expires when browser closes
|
||||
const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin });
|
||||
const signed = !!process.env.COOKIE_SECRET;
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||
});
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
signed
|
||||
};
|
||||
if (rememberMe) {
|
||||
cookieOptions.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
}
|
||||
res.cookie('emby_user', cookiePayload, cookieOptions);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.Id,
|
||||
name: user.Name,
|
||||
isAdmin: isAdmin
|
||||
}
|
||||
user: { id: user.Id, name: user.Name, isAdmin }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[Auth] Login failed:`, error.message);
|
||||
@@ -65,33 +96,55 @@ router.post('/login', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
function parseSessionCookie(req) {
|
||||
const signed = !!process.env.COOKIE_SECRET;
|
||||
const raw = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
|
||||
if (!raw || raw === false) return null; // false = tampered signed cookie
|
||||
try {
|
||||
const u = JSON.parse(raw);
|
||||
// Schema validation: require id (string), name (string), isAdmin (boolean)
|
||||
if (typeof u.id !== 'string' || !u.id) return null;
|
||||
if (typeof u.name !== 'string' || !u.name) return null;
|
||||
if (typeof u.isAdmin !== 'boolean') u.isAdmin = !!u.isAdmin;
|
||||
return u;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get current authenticated user
|
||||
router.get('/me', (req, res) => {
|
||||
try {
|
||||
const userCookie = req.cookies.emby_user;
|
||||
|
||||
if (!userCookie) {
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
|
||||
const user = JSON.parse(userCookie);
|
||||
res.json({
|
||||
authenticated: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
isAdmin: !!user.isAdmin
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[Auth] Error getting current user:`, error.message);
|
||||
res.json({ authenticated: false });
|
||||
}
|
||||
const user = parseSessionCookie(req);
|
||||
if (!user) return res.json({ authenticated: false });
|
||||
res.json({
|
||||
authenticated: true,
|
||||
user: { id: user.id, name: user.name, isAdmin: user.isAdmin }
|
||||
});
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', (req, res) => {
|
||||
res.clearCookie('emby_user');
|
||||
router.post('/logout', async (req, res) => {
|
||||
const user = parseSessionCookie(req);
|
||||
if (user) {
|
||||
const stored = getToken(user.id);
|
||||
if (stored) {
|
||||
try {
|
||||
await axios.post(`${EMBY_URL}/Sessions/Logout`, {}, {
|
||||
headers: { 'X-MediaBrowser-Token': stored.accessToken }
|
||||
});
|
||||
console.log(`[Auth] Revoked Emby token for user: ${user.name}`);
|
||||
} catch (err) {
|
||||
console.warn(`[Auth] Failed to revoke Emby token for ${user.name}:`, err.message);
|
||||
}
|
||||
clearToken(user.id);
|
||||
}
|
||||
}
|
||||
res.clearCookie('emby_user', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
signed: !!process.env.COOKIE_SECRET
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
||||
+355
-289
@@ -1,16 +1,14 @@
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
|
||||
const { getTorrents, mapTorrentToDownload } = require('../utils/qbittorrent');
|
||||
const {
|
||||
getSABnzbdInstances,
|
||||
getSonarrInstances,
|
||||
getRadarrInstances
|
||||
} = require('../utils/config');
|
||||
const axios = require('axios');
|
||||
const { mapTorrentToDownload } = require('../utils/qbittorrent');
|
||||
const cache = require('../utils/cache');
|
||||
const { pollAllServices, getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
|
||||
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
|
||||
const EMBY_URL = process.env.EMBY_URL;
|
||||
const EMBY_API_KEY = process.env.EMBY_API_KEY;
|
||||
|
||||
// Helper function to extract poster/cover art URL from a movie or series object
|
||||
function getCoverArt(item) {
|
||||
@@ -22,242 +20,200 @@ function getCoverArt(item) {
|
||||
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
|
||||
}
|
||||
|
||||
// Helper function to extract user tag from series/movie
|
||||
// For Radarr: tags is array of IDs, tagMap is id -> label mapping
|
||||
// For Sonarr: tags is array of objects with label property
|
||||
function extractUserTag(tags, tagMap) {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
|
||||
// If tagMap provided (Radarr), look up label by ID
|
||||
// Return all resolved tag labels for a series/movie.
|
||||
// For Radarr: tags is array of IDs, tagMap is id -> label mapping.
|
||||
// For Sonarr: tags are objects with a label property.
|
||||
function extractAllTags(tags, tagMap) {
|
||||
if (!tags || tags.length === 0) return [];
|
||||
if (tagMap) {
|
||||
for (const tagId of tags) {
|
||||
const label = tagMap.get(tagId);
|
||||
if (label) return label;
|
||||
}
|
||||
return null;
|
||||
return tags.map(id => tagMap.get(id)).filter(Boolean);
|
||||
}
|
||||
|
||||
// Sonarr style - tags are objects with label
|
||||
const userTag = tags.find(tag => tag && tag.label);
|
||||
return userTag ? userTag.label : null;
|
||||
return tags.map(t => t && t.label).filter(Boolean);
|
||||
}
|
||||
|
||||
// Return the tag label that matches the current username, or null.
|
||||
function extractUserTag(tags, tagMap, username) {
|
||||
const allLabels = extractAllTags(tags, tagMap);
|
||||
if (!allLabels.length) return null;
|
||||
if (username) {
|
||||
const match = allLabels.find(label => tagMatchesUser(label, username));
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim
|
||||
function sanitizeTagLabel(input) {
|
||||
if (!input) return '';
|
||||
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
// Check if a tag matches the username: exact match first, then sanitized match
|
||||
function tagMatchesUser(tag, username) {
|
||||
if (!tag || !username) return false;
|
||||
const tagLower = tag.toLowerCase();
|
||||
// Exact match (handles users whose tags weren't mangled)
|
||||
if (tagLower === username) return true;
|
||||
// Sanitized match (handles Ombi-mangled tags for email-style usernames)
|
||||
if (tagLower === sanitizeTagLabel(username)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract import issues from a Sonarr/Radarr queue record
|
||||
function getImportIssues(queueRecord) {
|
||||
if (!queueRecord) return null;
|
||||
const state = queueRecord.trackedDownloadState;
|
||||
const status = queueRecord.trackedDownloadStatus;
|
||||
if (state !== 'importPending' && status !== 'warning' && status !== 'error') return null;
|
||||
const messages = [];
|
||||
if (queueRecord.statusMessages && queueRecord.statusMessages.length > 0) {
|
||||
for (const sm of queueRecord.statusMessages) {
|
||||
if (sm.messages && sm.messages.length > 0) {
|
||||
messages.push(...sm.messages);
|
||||
} else if (sm.title) {
|
||||
messages.push(sm.title);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (queueRecord.errorMessage) {
|
||||
messages.push(queueRecord.errorMessage);
|
||||
}
|
||||
if (messages.length === 0) return null;
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Helper to build Sonarr web UI link for a series
|
||||
function getSonarrLink(series) {
|
||||
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
||||
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
||||
}
|
||||
|
||||
// Helper to build Radarr web UI link for a movie
|
||||
function getRadarrLink(movie) {
|
||||
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
|
||||
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||
}
|
||||
|
||||
// Fetch all Emby users and return a Map<lowerName -> displayName> (and sanitized variants).
|
||||
// Result is cached for 60s to avoid hammering Emby on every dashboard poll.
|
||||
async function getEmbyUsers() {
|
||||
const cached = cache.get('emby:users');
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
|
||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||
});
|
||||
// Build map: both raw lowercase and sanitized form -> display name
|
||||
const map = new Map();
|
||||
for (const u of response.data) {
|
||||
const name = u.Name || '';
|
||||
map.set(name.toLowerCase(), name);
|
||||
map.set(sanitizeTagLabel(name), name);
|
||||
}
|
||||
cache.set('emby:users', map, 60000);
|
||||
return map;
|
||||
} catch (err) {
|
||||
console.error('[Dashboard] Failed to fetch Emby users:', err.message);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
// Classify each tag label: matched to a known Emby user, or unmatched.
|
||||
// Returns array of { label, matchedUser: string|null }
|
||||
function buildTagBadges(allTags, embyUserMap) {
|
||||
return allTags.map(label => {
|
||||
const lower = label.toLowerCase();
|
||||
const sanitized = sanitizeTagLabel(label);
|
||||
const displayName = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
|
||||
return { label, matchedUser: displayName };
|
||||
});
|
||||
}
|
||||
|
||||
// Track active dashboard clients: Map<username, { refreshRateMs, lastSeen }>
|
||||
const activeClients = new Map();
|
||||
const CLIENT_STALE_MS = 30000; // consider client gone after 30s of no requests
|
||||
|
||||
function getActiveClients() {
|
||||
const now = Date.now();
|
||||
// Prune stale clients
|
||||
for (const [key, client] of activeClients.entries()) {
|
||||
if (now - client.lastSeen > CLIENT_STALE_MS) activeClients.delete(key);
|
||||
}
|
||||
return Array.from(activeClients.values());
|
||||
}
|
||||
|
||||
// Get user downloads for authenticated user
|
||||
router.get('/user-downloads', async (req, res) => {
|
||||
router.get('/user-downloads', requireAuth, async (req, res) => {
|
||||
try {
|
||||
// Get authenticated user from cookie
|
||||
const userCookie = req.cookies.emby_user;
|
||||
if (!userCookie) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const user = JSON.parse(userCookie);
|
||||
const user = req.user;
|
||||
const username = user.name.toLowerCase();
|
||||
const usernameSanitized = sanitizeTagLabel(user.name);
|
||||
const isAdmin = !!user.isAdmin;
|
||||
const showAll = isAdmin && req.query.showAll === 'true';
|
||||
console.log(`[Dashboard] Fetching downloads for authenticated user: ${user.name} (${username}), isAdmin: ${isAdmin}, showAll: ${showAll}`);
|
||||
console.log(`[Dashboard] Serving downloads for user: ${user.name} (${username}), isAdmin: ${isAdmin}, showAll: ${showAll}`);
|
||||
|
||||
// Get all service instances
|
||||
const sabInstances = getSABnzbdInstances();
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
console.log(`[Dashboard] Fetching data from all services...`);
|
||||
console.log(`[Dashboard] SABnzbd instances: ${sabInstances.length}`);
|
||||
console.log(`[Dashboard] Sonarr instances: ${sonarrInstances.length}`);
|
||||
console.log(`[Dashboard] Radarr instances: ${radarrInstances.length}`);
|
||||
|
||||
// Fetch from all SABnzbd instances
|
||||
const sabQueuePromises = sabInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api`, {
|
||||
params: { mode: 'queue', apikey: inst.apiKey, output: 'json' }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] SABnzbd ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { queue: { slots: [] } } };
|
||||
})
|
||||
);
|
||||
|
||||
const sabHistoryPromises = sabInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api`, {
|
||||
params: { mode: 'history', apikey: inst.apiKey, output: 'json', limit: 100 }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] SABnzbd ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { history: { slots: [] } } };
|
||||
})
|
||||
);
|
||||
|
||||
// Fetch from all Sonarr instances
|
||||
const sonarrTagsPromises = sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] Sonarr ${inst.id} tags error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
);
|
||||
|
||||
const sonarrQueuePromises = sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { includeSeries: true, includeEpisode: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] Sonarr ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
);
|
||||
|
||||
const sonarrHistoryPromises = sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { pageSize: 100, includeSeries: true, includeEpisode: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] Sonarr ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
);
|
||||
|
||||
const sonarrSeriesPromises = sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/series`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] Sonarr ${inst.id} series error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
);
|
||||
|
||||
// Fetch from all Radarr instances
|
||||
const radarrQueuePromises = radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { includeMovie: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] Radarr ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
);
|
||||
|
||||
const radarrHistoryPromises = radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { pageSize: 100, includeMovie: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] Radarr ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
);
|
||||
|
||||
const radarrMoviesPromises = radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/movie`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] Radarr ${inst.id} movies error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
);
|
||||
|
||||
const radarrTagsPromises = radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Dashboard] Radarr ${inst.id} tags error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
);
|
||||
|
||||
// Execute all requests
|
||||
const [
|
||||
sabQueues, sabHistories, sonarrTagsResults, sonarrQueues, sonarrHistories, sonarrSeriesResults,
|
||||
radarrQueues, radarrHistories, radarrMoviesResults, radarrTagsResults,
|
||||
qbittorrentTorrents
|
||||
] = await Promise.all([
|
||||
Promise.all(sabQueuePromises),
|
||||
Promise.all(sabHistoryPromises),
|
||||
Promise.all(sonarrTagsPromises),
|
||||
Promise.all(sonarrQueuePromises),
|
||||
Promise.all(sonarrHistoryPromises),
|
||||
Promise.all(sonarrSeriesPromises),
|
||||
Promise.all(radarrQueuePromises),
|
||||
Promise.all(radarrHistoryPromises),
|
||||
Promise.all(radarrMoviesPromises),
|
||||
Promise.all(radarrTagsPromises),
|
||||
getTorrents().catch(err => {
|
||||
console.error(`[Dashboard] qBittorrent error:`, err.message);
|
||||
return [];
|
||||
})
|
||||
]);
|
||||
|
||||
// Aggregate data from all instances
|
||||
const firstSabQueue = sabQueues[0] && sabQueues[0].data && sabQueues[0].data.queue;
|
||||
const sabnzbdQueue = {
|
||||
data: {
|
||||
queue: {
|
||||
slots: sabQueues.flatMap(q => (q.data.queue && q.data.queue.slots) || []),
|
||||
status: firstSabQueue && firstSabQueue.status,
|
||||
speed: firstSabQueue && firstSabQueue.speed,
|
||||
kbpersec: firstSabQueue && firstSabQueue.kbpersec
|
||||
}
|
||||
}
|
||||
};
|
||||
const sabnzbdHistory = {
|
||||
data: {
|
||||
history: {
|
||||
slots: sabHistories.flatMap(h => (h.data.history && h.data.history.slots) || [])
|
||||
}
|
||||
}
|
||||
};
|
||||
const sonarrQueue = {
|
||||
data: {
|
||||
records: sonarrQueues.flatMap(q => q.data.records || [])
|
||||
}
|
||||
};
|
||||
const sonarrHistory = {
|
||||
data: {
|
||||
records: sonarrHistories.flatMap(h => h.data.records || [])
|
||||
}
|
||||
};
|
||||
const sonarrSeries = {
|
||||
data: sonarrSeriesResults.flatMap(s => s.data || [])
|
||||
};
|
||||
const radarrQueue = {
|
||||
data: {
|
||||
records: radarrQueues.flatMap(q => q.data.records || [])
|
||||
}
|
||||
};
|
||||
const radarrHistory = {
|
||||
data: {
|
||||
records: radarrHistories.flatMap(h => h.data.records || [])
|
||||
}
|
||||
};
|
||||
const radarrMovies = {
|
||||
data: radarrMoviesResults.flatMap(m => m.data || [])
|
||||
};
|
||||
const radarrTags = {
|
||||
data: radarrTagsResults.flatMap(t => t.data || [])
|
||||
};
|
||||
|
||||
console.log(`[Dashboard] Data fetched successfully`);
|
||||
console.log(`[Dashboard] Sonarr series: ${sonarrSeries.data.length}`);
|
||||
console.log(`[Dashboard] Radarr movies: ${radarrMovies.data.length}`);
|
||||
console.log(`[Dashboard] Radarr queue records: ${radarrQueue.data.records.length}`);
|
||||
console.log(`[Dashboard] Radarr history records: ${radarrHistory.data.records.length}`);
|
||||
console.log(`[Dashboard] SABnzbd queue slots: ${sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.slots.length : 0}`);
|
||||
console.log(`[Dashboard] SABnzbd history slots: ${sabnzbdHistory.data.history ? sabnzbdHistory.data.history.slots.length : 0}`);
|
||||
console.log(`[Dashboard] qBittorrent torrents: ${qbittorrentTorrents.length}`);
|
||||
console.log(`[Dashboard] Radarr queue:`, JSON.stringify(radarrQueue.data.records));
|
||||
console.log(`[Dashboard] Radarr history:`, JSON.stringify(radarrHistory.data.records));
|
||||
// Track this client's refresh rate
|
||||
const clientRefreshRate = parseInt(req.query.refreshRate, 10);
|
||||
if (clientRefreshRate > 0) {
|
||||
activeClients.set(username, { user: user.name, refreshRateMs: clientRefreshRate, lastSeen: Date.now() });
|
||||
} else {
|
||||
// Client has refresh off or didn't send — still mark as seen but with no rate
|
||||
activeClients.set(username, { user: user.name, refreshRateMs: 0, lastSeen: Date.now() });
|
||||
}
|
||||
|
||||
// When polling is disabled, fetch on-demand if cache has expired
|
||||
// The fetched data is cached (30s TTL) so subsequent requests from any user reuse it
|
||||
if (!POLLING_ENABLED && !cache.get('poll:sab-queue')) {
|
||||
console.log(`[Dashboard] Cache expired and polling disabled, fetching on-demand...`);
|
||||
await pollAllServices();
|
||||
}
|
||||
|
||||
// Read all data from cache
|
||||
const sabQueueData = cache.get('poll:sab-queue') || { slots: [] };
|
||||
const sabHistoryData = cache.get('poll:sab-history') || { slots: [] };
|
||||
const sonarrTagsResults = cache.get('poll:sonarr-tags') || [];
|
||||
const sonarrQueueData = cache.get('poll:sonarr-queue') || { records: [] };
|
||||
const sonarrHistoryData = cache.get('poll:sonarr-history') || { records: [] };
|
||||
const radarrQueueData = cache.get('poll:radarr-queue') || { records: [] };
|
||||
const radarrHistoryData = cache.get('poll:radarr-history') || { records: [] };
|
||||
const radarrTagsData = cache.get('poll:radarr-tags') || [];
|
||||
const qbittorrentTorrents = cache.get('poll:qbittorrent') || [];
|
||||
|
||||
// Wrap in the structure the rest of the code expects
|
||||
const sabnzbdQueue = { data: { queue: sabQueueData } };
|
||||
const sabnzbdHistory = { data: { history: sabHistoryData } };
|
||||
const sonarrQueue = { data: sonarrQueueData };
|
||||
const sonarrHistory = { data: sonarrHistoryData };
|
||||
const radarrQueue = { data: radarrQueueData };
|
||||
const radarrHistory = { data: radarrHistoryData };
|
||||
const radarrTags = { data: radarrTagsData };
|
||||
|
||||
// Build series/movie maps from embedded objects in queue records
|
||||
// (history is fetched without includeSeries/includeMovie for speed;
|
||||
// history matches fall back to the queue-built map via seriesId/movieId)
|
||||
const seriesMap = new Map();
|
||||
for (const r of sonarrQueue.data.records) {
|
||||
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
||||
}
|
||||
for (const r of sonarrHistory.data.records) {
|
||||
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
|
||||
}
|
||||
const moviesMap = new Map();
|
||||
for (const r of radarrQueue.data.records) {
|
||||
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
||||
}
|
||||
for (const r of radarrHistory.data.records) {
|
||||
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
|
||||
}
|
||||
|
||||
// Create maps for quick lookup
|
||||
const seriesMap = new Map(sonarrSeries.data.map(s => [s.id, s]));
|
||||
const moviesMap = new Map(radarrMovies.data.map(m => [m.id, m]));
|
||||
|
||||
// Create tag maps (id -> 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]));
|
||||
console.log(`[Dashboard] Radarr tags:`, JSON.stringify(radarrTags.data));
|
||||
console.log(`[Dashboard] Movies map keys:`, Array.from(moviesMap.keys()).slice(0, 10));
|
||||
console.log(`[Dashboard] Looking for movieId: 2962`);
|
||||
console.log(`[Dashboard] Movie 2962:`, JSON.stringify(moviesMap.get(2962)));
|
||||
console.log(`[Dashboard] Sample movie structure:`, JSON.stringify(radarrMovies.data[0]));
|
||||
|
||||
// When showing all downloads, fetch full Emby user list to classify tags
|
||||
const embyUserMap = showAll ? await getEmbyUsers() : new Map();
|
||||
|
||||
console.log(`[Dashboard] Cache data - Series: ${seriesMap.size}, Movies: ${moviesMap.size}, qBit: ${qbittorrentTorrents.length}`);
|
||||
|
||||
// Match SABnzbd downloads to Sonarr/Radarr activity
|
||||
const userDownloads = [];
|
||||
@@ -302,9 +258,11 @@ router.get('/user-downloads', async (req, res) => {
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const userTag = extractUserTag(series.tags, sonarrTagMap);
|
||||
if (userTag && (showAll || userTag.toLowerCase() === username)) {
|
||||
userDownloads.push({
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
const hasAnyTag = allTags.length > 0;
|
||||
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
||||
const dlObj = {
|
||||
type: 'series',
|
||||
title: nzbName,
|
||||
coverArt: getCoverArt(series),
|
||||
@@ -317,8 +275,18 @@ router.get('/user-downloads', async (req, res) => {
|
||||
eta: slot.timeleft,
|
||||
seriesName: series.title,
|
||||
episodeInfo: sonarrMatch,
|
||||
userTag: userTag
|
||||
});
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||
};
|
||||
const issues = getImportIssues(sonarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = series.path || null;
|
||||
dlObj.arrLink = getSonarrLink(series);
|
||||
}
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,9 +300,11 @@ router.get('/user-downloads', async (req, res) => {
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const userTag = extractUserTag(movie.tags, radarrTagMap);
|
||||
if (userTag && (showAll || userTag.toLowerCase() === username)) {
|
||||
userDownloads.push({
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
const hasAnyTag = allTags.length > 0;
|
||||
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
||||
const dlObj = {
|
||||
type: 'movie',
|
||||
title: nzbName,
|
||||
coverArt: getCoverArt(movie),
|
||||
@@ -347,8 +317,18 @@ router.get('/user-downloads', async (req, res) => {
|
||||
eta: slot.timeleft,
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrMatch,
|
||||
userTag: userTag
|
||||
});
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||
};
|
||||
const issues = getImportIssues(radarrMatch);
|
||||
if (issues) dlObj.importIssues = issues;
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = movie.path || null;
|
||||
dlObj.arrLink = getRadarrLink(movie);
|
||||
}
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,9 +359,11 @@ router.get('/user-downloads', async (req, res) => {
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const userTag = extractUserTag(series.tags, sonarrTagMap);
|
||||
if (userTag && (showAll || userTag.toLowerCase() === username)) {
|
||||
userDownloads.push({
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
const hasAnyTag = allTags.length > 0;
|
||||
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
||||
const dlObj = {
|
||||
type: 'series',
|
||||
title: nzbName,
|
||||
coverArt: getCoverArt(series),
|
||||
@@ -390,8 +372,16 @@ router.get('/user-downloads', async (req, res) => {
|
||||
completedAt: slot.completed_time,
|
||||
seriesName: series.title,
|
||||
episodeInfo: sonarrMatch,
|
||||
userTag: userTag
|
||||
});
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||
};
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = series.path || null;
|
||||
dlObj.arrLink = getSonarrLink(series);
|
||||
}
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -405,9 +395,11 @@ router.get('/user-downloads', async (req, res) => {
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const userTag = extractUserTag(movie.tags, radarrTagMap);
|
||||
if (userTag && (showAll || userTag.toLowerCase() === username)) {
|
||||
userDownloads.push({
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
const hasAnyTag = allTags.length > 0;
|
||||
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
||||
const dlObj = {
|
||||
type: 'movie',
|
||||
title: nzbName,
|
||||
coverArt: getCoverArt(movie),
|
||||
@@ -416,8 +408,16 @@ router.get('/user-downloads', async (req, res) => {
|
||||
completedAt: slot.completed_time,
|
||||
movieName: movie.title,
|
||||
movieInfo: radarrMatch,
|
||||
userTag: userTag
|
||||
});
|
||||
allTags,
|
||||
matchedUserTag: matchedUserTag || null,
|
||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||
};
|
||||
if (isAdmin) {
|
||||
dlObj.downloadPath = slot.storage || null;
|
||||
dlObj.targetPath = movie.path || null;
|
||||
dlObj.arrLink = getRadarrLink(movie);
|
||||
}
|
||||
userDownloads.push(dlObj);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -436,14 +436,12 @@ router.get('/user-downloads', async (req, res) => {
|
||||
console.log(`[Dashboard] Sonarr tag map:`, Array.from(sonarrTagMap.entries()));
|
||||
console.log(`[Dashboard] Radarr tag map:`, Array.from(radarrTagMap.entries()));
|
||||
|
||||
// Show movies/series tagged for this user
|
||||
const userMovies = radarrMovies.data.filter(m => {
|
||||
const tag = extractUserTag(m.tags, radarrTagMap);
|
||||
return tag && tag.toLowerCase() === username;
|
||||
// Show movies/series tagged for this user (from embedded objects in queue/history)
|
||||
const userMovies = Array.from(moviesMap.values()).filter(m => {
|
||||
return !!extractUserTag(m.tags, radarrTagMap, username);
|
||||
});
|
||||
const userSeries = sonarrSeries.data.filter(s => {
|
||||
const tag = extractUserTag(s.tags, sonarrTagMap);
|
||||
return tag && tag.toLowerCase() === username;
|
||||
const userSeries = Array.from(seriesMap.values()).filter(s => {
|
||||
return !!extractUserTag(s.tags, sonarrTagMap, username);
|
||||
});
|
||||
console.log(`[Dashboard] Movies tagged for ${username}:`, userMovies.map(m => m.title));
|
||||
console.log(`[Dashboard] Series tagged for ${username}:`, userSeries.map(s => s.title));
|
||||
@@ -467,15 +465,26 @@ router.get('/user-downloads', async (req, res) => {
|
||||
if (sonarrMatch && sonarrMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
||||
if (series) {
|
||||
const userTag = extractUserTag(series.tags, sonarrTagMap);
|
||||
if (userTag && (showAll || userTag.toLowerCase() === username)) {
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
const hasAnyTag = allTags.length > 0;
|
||||
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
||||
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr series "${series.title}"`);
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.type = 'series';
|
||||
download.coverArt = getCoverArt(series);
|
||||
download.seriesName = series.title;
|
||||
download.episodeInfo = sonarrMatch;
|
||||
download.userTag = userTag;
|
||||
download.allTags = allTags;
|
||||
download.matchedUserTag = matchedUserTag || null;
|
||||
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
||||
const sonarrIssues = getImportIssues(sonarrMatch);
|
||||
if (sonarrIssues) download.importIssues = sonarrIssues;
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = series.path || null;
|
||||
download.arrLink = getSonarrLink(series);
|
||||
}
|
||||
userDownloads.push(download);
|
||||
continue; // Skip to next torrent
|
||||
}
|
||||
@@ -491,15 +500,26 @@ router.get('/user-downloads', async (req, res) => {
|
||||
if (radarrMatch && radarrMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
||||
if (movie) {
|
||||
const userTag = extractUserTag(movie.tags, radarrTagMap);
|
||||
if (userTag && (showAll || userTag.toLowerCase() === username)) {
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
const hasAnyTag = allTags.length > 0;
|
||||
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
||||
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr movie "${movie.title}"`);
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.type = 'movie';
|
||||
download.coverArt = getCoverArt(movie);
|
||||
download.movieName = movie.title;
|
||||
download.movieInfo = radarrMatch;
|
||||
download.userTag = userTag;
|
||||
download.allTags = allTags;
|
||||
download.matchedUserTag = matchedUserTag || null;
|
||||
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
||||
const radarrIssues = getImportIssues(radarrMatch);
|
||||
if (radarrIssues) download.importIssues = radarrIssues;
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = movie.path || null;
|
||||
download.arrLink = getRadarrLink(movie);
|
||||
}
|
||||
userDownloads.push(download);
|
||||
continue; // Skip to next torrent
|
||||
}
|
||||
@@ -515,15 +535,24 @@ router.get('/user-downloads', async (req, res) => {
|
||||
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
|
||||
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
|
||||
if (series) {
|
||||
const userTag = extractUserTag(series.tags, sonarrTagMap);
|
||||
if (userTag && (showAll || userTag.toLowerCase() === username)) {
|
||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||
const hasAnyTag = allTags.length > 0;
|
||||
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
||||
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr history "${series.title}"`);
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.type = 'series';
|
||||
download.coverArt = getCoverArt(series);
|
||||
download.seriesName = series.title;
|
||||
download.episodeInfo = sonarrHistoryMatch;
|
||||
download.userTag = userTag;
|
||||
download.allTags = allTags;
|
||||
download.matchedUserTag = matchedUserTag || null;
|
||||
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = series.path || null;
|
||||
download.arrLink = getSonarrLink(series);
|
||||
}
|
||||
userDownloads.push(download);
|
||||
continue;
|
||||
}
|
||||
@@ -539,15 +568,24 @@ router.get('/user-downloads', async (req, res) => {
|
||||
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
|
||||
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
|
||||
if (movie) {
|
||||
const userTag = extractUserTag(movie.tags, radarrTagMap);
|
||||
if (userTag && (showAll || userTag.toLowerCase() === username)) {
|
||||
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||
const hasAnyTag = allTags.length > 0;
|
||||
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
||||
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr history "${movie.title}"`);
|
||||
const download = mapTorrentToDownload(torrent);
|
||||
download.type = 'movie';
|
||||
download.coverArt = getCoverArt(movie);
|
||||
download.movieName = movie.title;
|
||||
download.movieInfo = radarrHistoryMatch;
|
||||
download.userTag = userTag;
|
||||
download.allTags = allTags;
|
||||
download.matchedUserTag = matchedUserTag || null;
|
||||
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
||||
if (isAdmin) {
|
||||
download.downloadPath = download.savePath || null;
|
||||
download.targetPath = movie.path || null;
|
||||
download.arrLink = getRadarrLink(movie);
|
||||
}
|
||||
userDownloads.push(download);
|
||||
continue;
|
||||
}
|
||||
@@ -573,19 +611,19 @@ router.get('/user-downloads', async (req, res) => {
|
||||
} catch (error) {
|
||||
console.error(`[Dashboard] Error fetching user downloads:`, error.message);
|
||||
console.error(`[Dashboard] Full error:`, error);
|
||||
res.status(500).json({ error: 'Failed to fetch user downloads', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch user downloads', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all users with their download counts
|
||||
router.get('/user-summary', async (req, res) => {
|
||||
router.get('/user-summary', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
// Get all Emby users
|
||||
const usersResponse = await axios.get(`${EMBY_URL}/Users`, {
|
||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||
const usersResponse = await axios.get(`${process.env.EMBY_URL}/Users`, {
|
||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||
});
|
||||
|
||||
// Get all series, movies, and tags from all instances
|
||||
@@ -629,29 +667,57 @@ router.get('/user-summary', async (req, res) => {
|
||||
|
||||
// Process series tags
|
||||
allSeries.forEach(series => {
|
||||
const userTag = extractUserTag(series.tags, sonarrTagMap);
|
||||
if (userTag) {
|
||||
const username = userTag.toLowerCase();
|
||||
if (userDownloads[username]) {
|
||||
userDownloads[username].seriesCount++;
|
||||
}
|
||||
}
|
||||
const tags = extractAllTags(series.tags, sonarrTagMap);
|
||||
tags.forEach(userTag => {
|
||||
const uname = userTag.toLowerCase();
|
||||
if (userDownloads[uname]) userDownloads[uname].seriesCount++;
|
||||
});
|
||||
});
|
||||
|
||||
// Process movie tags
|
||||
allMovies.forEach(movie => {
|
||||
const userTag = extractUserTag(movie.tags, radarrTagMap);
|
||||
if (userTag) {
|
||||
const username = userTag.toLowerCase();
|
||||
if (userDownloads[username]) {
|
||||
userDownloads[username].movieCount++;
|
||||
}
|
||||
}
|
||||
const tags = extractAllTags(movie.tags, radarrTagMap);
|
||||
tags.forEach(userTag => {
|
||||
const uname = userTag.toLowerCase();
|
||||
if (userDownloads[uname]) userDownloads[uname].movieCount++;
|
||||
});
|
||||
});
|
||||
|
||||
res.json(Object.values(userDownloads));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch user summary', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch user summary', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin-only status page with cache stats
|
||||
router.get('/status', requireAuth, (req, res) => {
|
||||
try {
|
||||
const user = req.user;
|
||||
if (!user.isAdmin) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
|
||||
const cacheStats = cache.getStats();
|
||||
const uptime = process.uptime();
|
||||
|
||||
res.json({
|
||||
server: {
|
||||
uptimeSeconds: Math.floor(uptime),
|
||||
nodeVersion: process.version,
|
||||
memoryUsageMB: Math.round(process.memoryUsage().rss / 1024 / 1024 * 10) / 10,
|
||||
heapUsedMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024 * 10) / 10,
|
||||
heapTotalMB: Math.round(process.memoryUsage().heapTotal / 1024 / 1024 * 10) / 10
|
||||
},
|
||||
polling: {
|
||||
enabled: POLLING_ENABLED,
|
||||
intervalMs: POLLING_ENABLED ? require('../utils/poller').POLL_INTERVAL : 0,
|
||||
lastPoll: getLastPollTimings()
|
||||
},
|
||||
cache: cacheStats,
|
||||
clients: getActiveClients()
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to get status', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+17
-16
@@ -1,51 +1,52 @@
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
|
||||
const EMBY_URL = process.env.EMBY_URL;
|
||||
const EMBY_API_KEY = process.env.EMBY_API_KEY;
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get active sessions
|
||||
router.get('/sessions', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${EMBY_URL}/Sessions`, {
|
||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
|
||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch Emby sessions', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch Emby sessions', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user by ID
|
||||
router.get('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${EMBY_URL}/Users/${req.params.id}`, {
|
||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Users/${req.params.id}`, {
|
||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch user details', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch user details', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all users
|
||||
router.get('/users', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${EMBY_URL}/Users`, {
|
||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
|
||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch users', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch users', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user by session ID
|
||||
router.get('/session/:sessionId/user', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${EMBY_URL}/Sessions`, {
|
||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||
const response = await axios.get(`${process.env.EMBY_URL}/Sessions`, {
|
||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||
});
|
||||
|
||||
const session = response.data.find(s => s.Id === req.params.sessionId);
|
||||
@@ -53,13 +54,13 @@ router.get('/session/:sessionId/user', async (req, res) => {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
const userResponse = await axios.get(`${EMBY_URL}/Users/${session.UserId}`, {
|
||||
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||
const userResponse = await axios.get(`${process.env.EMBY_URL}/Users/${session.UserId}`, {
|
||||
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
||||
});
|
||||
|
||||
res.json(userResponse.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch user from session', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch user from session', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+15
-14
@@ -1,56 +1,57 @@
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
|
||||
const RADARR_URL = process.env.RADARR_URL;
|
||||
const RADARR_API_KEY = process.env.RADARR_API_KEY;
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get queue
|
||||
router.get('/queue', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${RADARR_URL}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': RADARR_API_KEY }
|
||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch Radarr queue', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch Radarr queue', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get history
|
||||
router.get('/history', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${RADARR_URL}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': RADARR_API_KEY },
|
||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY },
|
||||
params: { pageSize: req.query.pageSize || 50 }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch Radarr history', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch Radarr history', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get movie details
|
||||
router.get('/movies/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${RADARR_URL}/api/v3/movie/${req.params.id}`, {
|
||||
headers: { 'X-Api-Key': RADARR_API_KEY }
|
||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie/${req.params.id}`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch movie details', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch movie details', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all movies with tags
|
||||
router.get('/movies', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${RADARR_URL}/api/v3/movie`, {
|
||||
headers: { 'X-Api-Key': RADARR_API_KEY }
|
||||
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/movie`, {
|
||||
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch movies', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch movies', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
|
||||
const SABNZBD_URL = process.env.SABNZBD_URL;
|
||||
const SABNZBD_API_KEY = process.env.SABNZBD_API_KEY;
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get current queue
|
||||
router.get('/queue', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${SABNZBD_URL}/api`, {
|
||||
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
|
||||
params: {
|
||||
mode: 'queue',
|
||||
apikey: SABNZBD_API_KEY,
|
||||
apikey: process.env.SABNZBD_API_KEY,
|
||||
output: 'json'
|
||||
}
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get history
|
||||
router.get('/history', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${SABNZBD_URL}/api`, {
|
||||
const response = await axios.get(`${process.env.SABNZBD_URL}/api`, {
|
||||
params: {
|
||||
mode: 'history',
|
||||
apikey: SABNZBD_API_KEY,
|
||||
apikey: process.env.SABNZBD_API_KEY,
|
||||
output: 'json',
|
||||
limit: req.query.limit || 50
|
||||
}
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+15
-14
@@ -1,56 +1,57 @@
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const router = express.Router();
|
||||
const requireAuth = require('../middleware/requireAuth');
|
||||
const sanitizeError = require('../utils/sanitizeError');
|
||||
|
||||
const SONARR_URL = process.env.SONARR_URL;
|
||||
const SONARR_API_KEY = process.env.SONARR_API_KEY;
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get queue
|
||||
router.get('/queue', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${SONARR_URL}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': SONARR_API_KEY }
|
||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get history
|
||||
router.get('/history', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${SONARR_URL}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': SONARR_API_KEY },
|
||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY },
|
||||
params: { pageSize: req.query.pageSize || 50 }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch Sonarr history', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch Sonarr history', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get series details
|
||||
router.get('/series/:id', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${SONARR_URL}/api/v3/series/${req.params.id}`, {
|
||||
headers: { 'X-Api-Key': SONARR_API_KEY }
|
||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series/${req.params.id}`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch series details', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch series details', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all series with tags
|
||||
router.get('/series', async (req, res) => {
|
||||
try {
|
||||
const response = await axios.get(`${SONARR_URL}/api/v3/series`, {
|
||||
headers: { 'X-Api-Key': SONARR_API_KEY }
|
||||
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/series`, {
|
||||
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||
});
|
||||
res.json(response.data);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch series', details: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch series', details: sanitizeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
const { logToFile } = require('./logger');
|
||||
|
||||
class MemoryCache {
|
||||
constructor() {
|
||||
this.store = new Map();
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key, value, ttlMs) {
|
||||
this.store.set(key, {
|
||||
value,
|
||||
expiresAt: Date.now() + ttlMs
|
||||
});
|
||||
}
|
||||
|
||||
invalidate(key) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.store.clear();
|
||||
}
|
||||
|
||||
getStats() {
|
||||
const now = Date.now();
|
||||
const entries = [];
|
||||
let totalSize = 0;
|
||||
|
||||
for (const [key, entry] of this.store.entries()) {
|
||||
const json = JSON.stringify(entry.value);
|
||||
const sizeBytes = Buffer.byteLength(json, 'utf8');
|
||||
totalSize += sizeBytes;
|
||||
const ttlRemaining = Math.max(0, entry.expiresAt - now);
|
||||
const expired = now > entry.expiresAt;
|
||||
let itemCount = null;
|
||||
if (Array.isArray(entry.value)) {
|
||||
itemCount = entry.value.length;
|
||||
} else if (entry.value && typeof entry.value === 'object') {
|
||||
if (Array.isArray(entry.value.records)) itemCount = entry.value.records.length;
|
||||
else if (Array.isArray(entry.value.slots)) itemCount = entry.value.slots.length;
|
||||
}
|
||||
entries.push({
|
||||
key,
|
||||
sizeBytes,
|
||||
itemCount,
|
||||
ttlRemainingMs: ttlRemaining,
|
||||
expired
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
entryCount: this.store.size,
|
||||
totalSizeBytes: totalSize,
|
||||
entries
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const cache = new MemoryCache();
|
||||
|
||||
module.exports = cache;
|
||||
@@ -0,0 +1,219 @@
|
||||
const axios = require('axios');
|
||||
const cache = require('./cache');
|
||||
const { getTorrents } = require('./qbittorrent');
|
||||
const {
|
||||
getSABnzbdInstances,
|
||||
getSonarrInstances,
|
||||
getRadarrInstances
|
||||
} = require('./config');
|
||||
|
||||
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
|
||||
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
|
||||
? 0
|
||||
: (parseInt(process.env.POLL_INTERVAL, 10) || 5000);
|
||||
const POLLING_ENABLED = POLL_INTERVAL > 0;
|
||||
|
||||
let polling = false;
|
||||
let lastPollTimings = null;
|
||||
|
||||
// Timed fetch helper: runs a fetch and records how long it took
|
||||
async function timed(label, fn) {
|
||||
const t0 = Date.now();
|
||||
const result = await fn();
|
||||
return { label, result, ms: Date.now() - t0 };
|
||||
}
|
||||
|
||||
async function pollAllServices() {
|
||||
if (polling) {
|
||||
console.log('[Poller] Previous poll still running, skipping');
|
||||
return;
|
||||
}
|
||||
polling = true;
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const sabInstances = getSABnzbdInstances();
|
||||
const sonarrInstances = getSonarrInstances();
|
||||
const radarrInstances = getRadarrInstances();
|
||||
|
||||
// All fetches in parallel, each individually timed
|
||||
const results = await Promise.all([
|
||||
timed('SABnzbd Queue', () => Promise.all(sabInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api`, {
|
||||
params: { mode: 'queue', apikey: inst.apiKey, output: 'json' }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] SABnzbd ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { queue: { slots: [] } } };
|
||||
})
|
||||
))),
|
||||
timed('SABnzbd History', () => Promise.all(sabInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api`, {
|
||||
params: { mode: 'history', apikey: inst.apiKey, output: 'json', limit: 10 }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] SABnzbd ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { history: { slots: [] } } };
|
||||
})
|
||||
))),
|
||||
timed('Sonarr Tags', () => Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Sonarr ${inst.id} tags error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
))),
|
||||
timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { includeSeries: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
))),
|
||||
timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { pageSize: 10 }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
))),
|
||||
timed('Radarr Queue', () => Promise.all(radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/queue`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { includeMovie: true }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Radarr ${inst.id} queue error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
))),
|
||||
timed('Radarr History', () => Promise.all(radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/history`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey },
|
||||
params: { pageSize: 10 }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Radarr ${inst.id} history error:`, err.message);
|
||||
return { instance: inst.id, data: { records: [] } };
|
||||
})
|
||||
))),
|
||||
timed('Radarr Tags', () => Promise.all(radarrInstances.map(inst =>
|
||||
axios.get(`${inst.url}/api/v3/tag`, {
|
||||
headers: { 'X-Api-Key': inst.apiKey }
|
||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||
console.error(`[Poller] Radarr ${inst.id} tags error:`, err.message);
|
||||
return { instance: inst.id, data: [] };
|
||||
})
|
||||
))),
|
||||
timed('qBittorrent', () => getTorrents().catch(err => {
|
||||
console.error(`[Poller] qBittorrent error:`, err.message);
|
||||
return [];
|
||||
}))
|
||||
]);
|
||||
|
||||
const [
|
||||
{ result: sabQueues }, { result: sabHistories },
|
||||
{ result: sonarrTagsResults }, { result: sonarrQueues },
|
||||
{ result: sonarrHistories },
|
||||
{ result: radarrQueues }, { result: radarrHistories },
|
||||
{ result: radarrTagsResults },
|
||||
{ result: qbittorrentTorrents }
|
||||
] = results;
|
||||
|
||||
// Store per-task timings
|
||||
const totalMs = Date.now() - start;
|
||||
lastPollTimings = {
|
||||
totalMs,
|
||||
timestamp: new Date().toISOString(),
|
||||
tasks: results.map(r => ({ label: r.label, ms: r.ms }))
|
||||
};
|
||||
|
||||
// When polling is active, TTL is 3x interval to avoid gaps between polls
|
||||
// When polling is disabled (on-demand), use 30s so data refreshes on next request after expiry
|
||||
const cacheTTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000;
|
||||
|
||||
// SABnzbd
|
||||
const firstSabQueue = sabQueues[0] && sabQueues[0].data && sabQueues[0].data.queue;
|
||||
cache.set('poll:sab-queue', {
|
||||
slots: sabQueues.flatMap(q => (q.data.queue && q.data.queue.slots) || []),
|
||||
status: firstSabQueue && firstSabQueue.status,
|
||||
speed: firstSabQueue && firstSabQueue.speed,
|
||||
kbpersec: firstSabQueue && firstSabQueue.kbpersec
|
||||
}, cacheTTL);
|
||||
|
||||
cache.set('poll:sab-history', {
|
||||
slots: sabHistories.flatMap(h => (h.data.history && h.data.history.slots) || [])
|
||||
}, cacheTTL);
|
||||
|
||||
// Sonarr
|
||||
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
|
||||
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
|
||||
cache.set('poll:sonarr-queue', {
|
||||
records: sonarrQueues.flatMap(q => {
|
||||
const inst = sonarrInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
return (q.data.records || []).map(r => {
|
||||
if (r.series) r.series._instanceUrl = url;
|
||||
return r;
|
||||
});
|
||||
})
|
||||
}, cacheTTL);
|
||||
cache.set('poll:sonarr-history', {
|
||||
records: sonarrHistories.flatMap(h => h.data.records || [])
|
||||
}, cacheTTL);
|
||||
|
||||
// Radarr
|
||||
cache.set('poll:radarr-queue', {
|
||||
records: radarrQueues.flatMap(q => {
|
||||
const inst = radarrInstances.find(i => i.id === q.instance);
|
||||
const url = inst ? inst.url : null;
|
||||
return (q.data.records || []).map(r => {
|
||||
if (r.movie) r.movie._instanceUrl = url;
|
||||
return r;
|
||||
});
|
||||
})
|
||||
}, cacheTTL);
|
||||
cache.set('poll:radarr-history', {
|
||||
records: radarrHistories.flatMap(h => h.data.records || [])
|
||||
}, cacheTTL);
|
||||
cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), cacheTTL);
|
||||
|
||||
// qBittorrent
|
||||
cache.set('poll:qbittorrent', qbittorrentTorrents, cacheTTL);
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
console.log(`[Poller] Poll complete in ${elapsed}ms`);
|
||||
} catch (err) {
|
||||
console.error(`[Poller] Poll error:`, err.message);
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
let intervalHandle = null;
|
||||
|
||||
function startPoller() {
|
||||
if (!POLLING_ENABLED) {
|
||||
console.log(`[Poller] Background polling disabled (POLL_INTERVAL=${process.env.POLL_INTERVAL || 'not set'}). Data will be fetched on-demand.`);
|
||||
return;
|
||||
}
|
||||
console.log(`[Poller] Starting background poller (interval: ${POLL_INTERVAL}ms)`);
|
||||
// Run immediately, then on interval
|
||||
pollAllServices();
|
||||
intervalHandle = setInterval(pollAllServices, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
function stopPoller() {
|
||||
if (intervalHandle) {
|
||||
clearInterval(intervalHandle);
|
||||
intervalHandle = null;
|
||||
console.log('[Poller] Stopped');
|
||||
}
|
||||
}
|
||||
|
||||
function getLastPollTimings() {
|
||||
return lastPollTimings;
|
||||
}
|
||||
|
||||
module.exports = { startPoller, stopPoller, pollAllServices, getLastPollTimings, POLL_INTERVAL, POLLING_ENABLED };
|
||||
@@ -96,14 +96,19 @@ class QBittorrentClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Persist clients so auth cookies survive between requests
|
||||
let persistedClients = null;
|
||||
|
||||
function getClients() {
|
||||
if (persistedClients) return persistedClients;
|
||||
const instances = getQbittorrentInstances();
|
||||
if (instances.length === 0) {
|
||||
logToFile('[qBittorrent] No instances configured');
|
||||
return [];
|
||||
}
|
||||
logToFile(`[qBittorrent] Created ${instances.length} client(s)`);
|
||||
return instances.map(inst => new QBittorrentClient(inst));
|
||||
logToFile(`[qBittorrent] Created ${instances.length} persistent client(s)`);
|
||||
persistedClients = instances.map(inst => new QBittorrentClient(inst));
|
||||
return persistedClients;
|
||||
}
|
||||
|
||||
async function getAllTorrents() {
|
||||
@@ -198,6 +203,7 @@ function mapTorrentToDownload(torrent) {
|
||||
hash: torrent.hash,
|
||||
category: torrent.category,
|
||||
tags: torrent.tags,
|
||||
savePath: torrent.content_path || torrent.save_path || null,
|
||||
qbittorrent: true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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