diff --git a/.env.example b/.env.example index 5f9d030..4120a26 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,10 @@ # Server Configuration PORT=3001 +LOG_LEVEL=info + +# 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 +21,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"}] diff --git a/.env.sample b/.env.sample index 364acb9..a302aff 100644 --- a/.env.sample +++ b/.env.sample @@ -14,6 +14,14 @@ PORT=3001 # - silent: No logging LOG_LEVEL=info +# 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 +82,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 # ============================================================================= diff --git a/.gitea/workflows/build-image.yml b/.gitea/workflows/build-image.yml index 9f58d86..681a5f8 100644 --- a/.gitea/workflows/build-image.yml +++ b/.gitea/workflows/build-image.yml @@ -4,6 +4,7 @@ on: push: branches: - 'release/**' + - 'develop' jobs: build: @@ -12,25 +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}" + + 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 - 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 }} diff --git a/README.md b/README.md index e8d420e..9a709a4 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..e35a2c4 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,609 @@ +# 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 +│ └── 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 +│ └── 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 (CORS, 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 | Login, session check, logout | +| `dashboard.js` | `/api/dashboard` | Yes (cookie) | Aggregated download data, status | +| `emby.js` | `/api/emby` | No | Proxy to Emby API | +| `sabnzbd.js` | `/api/sabnzbd` | No | Proxy to SABnzbd API | +| `sonarr.js` | `/api/sonarr` | No | Proxy to Sonarr API | +| `radarr.js` | `/api/radarr` | No | Proxy to Radarr API | + +> **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, token }` +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 | + +### 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`, 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 | +| `userTag` | string | Matched user tag | +| `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). + +--- + +### `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` (754 lines), 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 | +| `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 ``: +- **Light** — Purple gradient header, white cards +- **Dark** — Dark surfaces, muted accents +- **Mono** — Monochrome, minimal colour + +Theme selection persists in `localStorage`. + +### 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) diff --git a/docs/diagrams/activity-matching.puml b/docs/diagrams/activity-matching.puml new file mode 100644 index 0000000..c74b956 --- /dev/null +++ b/docs/diagrams/activity-matching.puml @@ -0,0 +1,128 @@ +@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); + +: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) + :userTag = extractUserTag(series.tags, sonarrTagMap); + if (showAll OR tagMatchesUser?) then (yes) + :Build download object (type=series) + Add coverArt, status, progress, speed, eta + Add importIssues if any + Add admin fields (paths, arrLink); + :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) + :userTag = extractUserTag(movie.tags, radarrTagMap); + if (showAll OR tagMatchesUser?) then (yes) + :Build download object (type=movie) + Add coverArt, status, progress, speed, eta + Add importIssues if any + Add admin fields (paths, arrLink); + :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) + :Check user tag, build download\n(type=series, with completedAt); + :Push to **userDownloads** if tag matches; + endif + endif + + if (Title matches Radarr **history** record?) then (yes) + :movie = moviesMap.get(match.movieId)\n|| match.movie; + if (movie found?) then (yes) + :Check user tag, build download\n(type=movie, with completedAt); + :Push to **userDownloads** if tag matches; + 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**: + 1. Exact: tag.toLowerCase() === username + 2. Sanitised: sanitizeTagLabel(tag) === sanitizeTagLabel(username) + (handles Ombi-mangled email-style usernames) +end legend + +@enduml diff --git a/docs/diagrams/class-data.puml b/docs/diagrams/class-data.puml new file mode 100644 index 0000000..ed92cf4 --- /dev/null +++ b/docs/diagrams/class-data.puml @@ -0,0 +1,221 @@ +@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 + + userTag : string + + 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 "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 + + token : string + } + + 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 : userTag resolution +eu ..> cookie : login creates + +@enduml diff --git a/docs/diagrams/class-server.puml b/docs/diagrams/class-server.puml new file mode 100644 index 0000000..7e7042b --- /dev/null +++ b/docs/diagrams/class-server.puml @@ -0,0 +1,197 @@ +@startuml class-server +!theme plain +title sofarr — Server Class / Module Diagram + +package "server/index.js" as entry { + class "EntryPoint" as ep <> { + - 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 <> { + + POST /login + + GET /me + + POST /logout + -- + Authenticates via Emby API + Sets/reads httpOnly cookie + } + + class "dashboard.js" as dashboard <> { + - activeClients : Map + - CLIENT_STALE_MS : 30000 + -- + + GET /user-downloads + + GET /user-summary + + GET /status + -- + - getCoverArt(item) : string|null + - extractUserTag(tags, tagMap) : string|null + - 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 <> { + + GET /sessions + + GET /users/:id + + GET /users + + GET /session/:sessionId/user + } + + class "sabnzbd.js" as sab_r <> { + + GET /queue + + GET /history + } + + class "sonarr.js" as sonarr_r <> { + + GET /queue + + GET /history + + GET /series/:id + + GET /series + } + + class "radarr.js" as radarr_r <> { + + GET /queue + + GET /history + + GET /movies/:id + + GET /movies + } +} + +package "server/utils" { + class "MemoryCache" as cache { + - store : Map + + get(key) : any|null + + set(key, value, ttlMs) : void + + invalidate(key) : void + + clear() : void + + getStats() : CacheStats + } + + class "CacheEntry" as ce <> { + + value : any + + expiresAt : number + } + + class "CacheStats" as cs <> { + + entryCount : number + + totalSizeBytes : number + + entries : CacheEntryStats[] + } + + class "Poller" as poller <> { + - POLL_INTERVAL : number + - POLLING_ENABLED : boolean + - polling : boolean + - lastPollTimings : PollTimings|null + - intervalHandle : number|null + -- + + startPoller() : void + + stopPoller() : void + + pollAllServices() : Promise + + getLastPollTimings() : PollTimings|null + -- + - timed(label, fn) : TimedResult + } + + class "PollTimings" as pt <> { + + totalMs : number + + timestamp : string (ISO) + + tasks : { label, ms }[] + } + + class "Config" as config <> { + + getSABnzbdInstances() : Instance[] + + getSonarrInstances() : Instance[] + + getRadarrInstances() : Instance[] + + getQbittorrentInstances() : Instance[] + -- + - parseInstances(envVar, ...) : Instance[] + } + + class "Instance" as inst <> { + + 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 + + makeRequest(endpoint, config) : Promise + + getTorrents() : Promise + } + + class "qbittorrent.js" as qbt_mod <> { + - persistedClients : QBittorrentClient[]|null + -- + + getTorrents() : Promise + + getClients() : QBittorrentClient[] + + mapTorrentToDownload(torrent) : Download + + formatBytes(bytes) : string + + formatSpeed(bps) : string + + formatEta(seconds) : string + } + + class "Logger" as logger <> { + - logFile : WriteStream + + logToFile(message) : void + } + + class "ClientInfo" as ci <> { + + user : string + + refreshRateMs : number + + lastSeen : number (timestamp) + } +} + +' Relationships +ep --> auth +ep --> dashboard +ep --> emby_r +ep --> sab_r +ep --> sonarr_r +ep --> radarr_r +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 diff --git a/docs/diagrams/component.puml b/docs/diagrams/component.puml new file mode 100644 index 0000000..61f5191 --- /dev/null +++ b/docs/diagrams/component.puml @@ -0,0 +1,94 @@ +@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" { + [CORS] as cors + [cookie-parser] as cp + [express.json] as ej + [express.static] as es + } + + 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 --> cors + entry --> cp + entry --> ej + entry --> es + entry --> auth + entry --> dashboard + entry --> emby_route + entry --> sab_route + entry --> sonarr_route + entry --> radarr_route + 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 : /user-summary\n(live fetch) +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 diff --git a/docs/diagrams/seq-auth.puml b/docs/diagrams/seq-auth.puml new file mode 100644 index 0000000..f81c644 --- /dev/null +++ b/docs/diagrams/seq-auth.puml @@ -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, token }\n(24h TTL) + 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 diff --git a/docs/diagrams/seq-dashboard.puml b/docs/diagrams/seq-dashboard.puml new file mode 100644 index 0000000..98864dc --- /dev/null +++ b/docs/diagrams/seq-dashboard.puml @@ -0,0 +1,85 @@ +@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) + +group SABnzbd Queue Matching + loop each queue slot + dashboard -> dashboard : Match title vs Sonarr queue + dashboard -> dashboard : Match title vs Radarr queue + dashboard -> dashboard : Resolve series/movie\n→ extract user tag\n→ filter by username + end +end + +group SABnzbd History Matching + loop each history slot + dashboard -> dashboard : Match title vs Sonarr history + dashboard -> dashboard : Match title vs Radarr history + dashboard -> dashboard : Resolve via seriesMap/moviesMap\n→ extract user tag\n→ filter + 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 with series/movie info + end +end + +dashboard --> browser : { user, isAdmin,\ndownloads: [...] } +deactivate dashboard + +browser -> browser : renderDownloads()\n(diff-based update) +deactivate browser + +@enduml diff --git a/docs/diagrams/seq-polling.puml b/docs/diagrams/seq-polling.puml new file mode 100644 index 0000000..5014f2d --- /dev/null +++ b/docs/diagrams/seq-polling.puml @@ -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 diff --git a/docs/diagrams/state-poller.puml b/docs/diagrams/state-poller.puml new file mode 100644 index 0000000..e3bf585 --- /dev/null +++ b/docs/diagrams/state-poller.puml @@ -0,0 +1,65 @@ +@startuml state-poller +!theme plain +title sofarr — Poller State Diagram + +[*] --> CheckConfig : startPoller() + +state CheckConfig <> +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 diff --git a/docs/diagrams/state-ui.puml b/docs/diagrams/state-ui.puml new file mode 100644 index 0000000..5642922 --- /dev/null +++ b/docs/diagrams/state-ui.puml @@ -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 <> +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 diff --git a/public/app.js b/public/app.js index c01cc9f..ea037f6 100644 --- a/public/app.js +++ b/public/app.js @@ -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,42 @@ 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'); + splash.addEventListener('transitionend', () => { + 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 +117,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(); } } @@ -103,9 +151,14 @@ async function handleLogin(e) { if (data.success) { currentUser = data.user; isAdmin = !!data.user.isAdmin; + // Fade out login, then show splash while loading data + await fadeOutLogin(); + showSplash(); showDashboard(); - fetchUserDownloads(true); + const splashStart = Date.now(); + await fetchUserDownloads(true); startAutoRefresh(); + await dismissSplash(splashStart); } else { showLoginError(data.error || 'Login failed'); } @@ -160,7 +213,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 +396,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,14 +415,22 @@ 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: ' + escapeHtml(download.seriesName) + ''; + } 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: ' + escapeHtml(download.movieName) + ''; + } else { + movie.textContent = `Movie: ${download.movieName}`; + } infoDiv.appendChild(movie); } @@ -458,6 +530,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 = 'Download: ' + escapeHtml(download.downloadPath) + ''; + pathsDiv.appendChild(dlPath); + } + if (download.targetPath) { + const tgtPath = document.createElement('div'); + tgtPath.className = 'path-item'; + tgtPath.innerHTML = 'Target: ' + escapeHtml(download.targetPath) + ''; + pathsDiv.appendChild(tgtPath); + } + details.appendChild(pathsDiv); + } infoDiv.appendChild(details); card.appendChild(infoDiv); @@ -484,6 +574,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 = '

Failed to load status.

'; + } + } +} + +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 = ` +
+

Server Status

+ +
+
+
+
Server
+
Uptime${uptime}
+
Node${escapeHtml(s.nodeVersion)}
+
Memory (RSS)${s.memoryUsageMB} MB
+
Heap${s.heapUsedMB} / ${s.heapTotalMB} MB
+
+
+
Data Refresh
`; + + 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 += `
Background poll${pollIntervalMs / 1000}s
`; + } else { + html += `
Background pollDisabled
`; + } + + if (hasForegroundClient) { + html += `
Effective modeForeground ${fastestClient.refreshRateMs / 1000}s
`; + } else if (activeRefreshers.length > 0) { + html += `
Effective mode${data.polling.enabled ? 'Background' : 'On-demand'}
`; + } else { + html += `
Effective modeIdle (no active clients)
`; + } + + html += `
Active clients${clients.length}
`; + 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 += `
${escapeHtml(c.user)}${rate} (${age}s ago)
`; + } + + html += `
`; + + // Poll timings card + const lp = data.polling.lastPoll; + if (lp) { + const pollAge = Math.round((Date.now() - new Date(lp.timestamp).getTime()) / 1000); + html += ` +
+
Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)
+
`; + for (const t of lp.tasks) { + const barWidth = lp.totalMs > 0 ? Math.max(2, (t.ms / lp.totalMs) * 100) : 0; + html += ` +
+ ${escapeHtml(t.label)} +
+ ${t.ms}ms +
`; + } + html += `
`; + } + + // Cache table + html += ` +
+
Cache (${data.cache.entryCount} entries, ${totalKB} KB)
+ + + `; + + 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 ? 'expired' : (e.ttlRemainingMs / 1000).toFixed(0) + 's'; + const items = e.itemCount !== null ? e.itemCount : '—'; + html += ``; + } + + html += `
KeyItemsSizeTTL
${escapeHtml(e.key)}${items}${sizeStr}${ttlStr}
`; + panel.innerHTML = html; +} + function formatSize(size) { if (!size) return 'N/A'; // If already a formatted string (e.g., "21.5 GB"), return as-is diff --git a/public/images/sofarr-flashscreen.png b/public/images/sofarr-flashscreen.png new file mode 100644 index 0000000..b738d12 Binary files /dev/null and b/public/images/sofarr-flashscreen.png differ diff --git a/public/index.html b/public/index.html index dbf5ed5..6c49234 100644 --- a/public/index.html +++ b/public/index.html @@ -7,11 +7,17 @@ + +
+ +
+