Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3edd442f5 | |||
| e4be334ad4 | |||
| bdd78407bb | |||
| 37c8229061 | |||
| d1496a76e2 | |||
| 80d43fbaa8 | |||
| c1fb55c5b8 | |||
| 742f34f6eb | |||
| 2b089871a0 | |||
| e8ffd7f7dd | |||
| dd7e3e2a90 | |||
| 557137421d | |||
| 71880c6298 | |||
| 6b995a136d | |||
| fa3c625fb8 | |||
| 57b3254f70 | |||
| eb321312dc | |||
| ddcfbda0c2 | |||
| ffd9e84a00 | |||
| 2a674c6bcd | |||
| da0898f52a | |||
| 5d7b126c5e | |||
| 224ec33a14 | |||
| cc8de12740 | |||
| a05aaf8d71 | |||
| 9751dbf98d | |||
| 29d7bdb536 | |||
| 6c847a26d3 | |||
| 7b4ba86435 | |||
| 28f2aa17d8 | |||
| aa8a6a49f4 | |||
| 341c619d3d | |||
| 0ffe62e1ca | |||
| 925d0c7735 | |||
| f88c81cc59 | |||
| 121c49b35b | |||
| a4004f5e7a | |||
| fd0d5cf6ec | |||
| 1f293ae70b | |||
| 352118b4af | |||
| e33f1debc0 | |||
| f41d14b2a9 | |||
| f5ef2c5991 | |||
| 240fc0d3b6 | |||
| c3ae3a80de | |||
| 94fe0dea4d | |||
| 3c3382401c |
@@ -1,37 +0,0 @@
|
|||||||
# 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
|
|
||||||
|
|
||||||
# Set to 1 (or a specific IP/CIDR) when running behind a reverse proxy
|
|
||||||
# (Nginx, Caddy, Traefik) so Express trusts X-Forwarded-For/Proto.
|
|
||||||
# Leave unset if sofarr is exposed directly.
|
|
||||||
# TRUST_PROXY=1
|
|
||||||
|
|
||||||
# Directory for persistent data (SQLite token store + logs)
|
|
||||||
# Defaults to ./data relative to project root
|
|
||||||
# DATA_DIR=/app/data
|
|
||||||
|
|
||||||
# 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
|
|
||||||
EMBY_API_KEY=your_emby_api_key
|
|
||||||
|
|
||||||
# SABnzbd Instances (JSON array)
|
|
||||||
# Format: [{"name": "Instance Name", "url": "http://...", "apiKey": "..."}]
|
|
||||||
SABNZBD_INSTANCES=[{"name": "Primary", "url": "http://localhost:8080", "apiKey": "your_api_key"}]
|
|
||||||
|
|
||||||
# Sonarr Instances (JSON array)
|
|
||||||
SONARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:8989", "apiKey": "your_api_key"}]
|
|
||||||
|
|
||||||
# Radarr Instances (JSON array)
|
|
||||||
RADARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:7878", "apiKey": "your_api_key"}]
|
|
||||||
|
|
||||||
# qBittorrent Instances (JSON array)
|
|
||||||
QBITTORRENT_INSTANCES=[{"name": "main", "url": "http://localhost:8080", "username": "admin", "password": "your_password"}]
|
|
||||||
@@ -19,6 +19,24 @@ LOG_LEVEL=info
|
|||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
COOKIE_SECRET=your-cookie-secret-here
|
COOKIE_SECRET=your-cookie-secret-here
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TLS / HTTPS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# TLS is enabled by default using the bundled snakeoil self-signed certificate
|
||||||
|
# (valid for localhost/127.0.0.1, 10-year expiry).
|
||||||
|
# Set TLS_CERT and TLS_KEY to use your own certificate (recommended).
|
||||||
|
# Set TLS_ENABLED=false to run in plain HTTP mode (e.g. behind a TLS proxy).
|
||||||
|
#
|
||||||
|
# To generate a self-signed cert for your own hostname:
|
||||||
|
# openssl req -x509 -newkey rsa:2048 -keyout server.key -out server.crt \
|
||||||
|
# -days 365 -nodes -subj "/CN=yourhostname" \
|
||||||
|
# -addext "subjectAltName=DNS:yourhostname,IP:192.168.x.x"
|
||||||
|
#
|
||||||
|
# TLS_ENABLED=true
|
||||||
|
# TLS_CERT=/path/to/server.crt
|
||||||
|
# TLS_KEY=/path/to/server.key
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# REVERSE PROXY & DEPLOYMENT
|
# REVERSE PROXY & DEPLOYMENT
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -34,6 +52,10 @@ COOKIE_SECRET=your-cookie-secret-here
|
|||||||
# Defaults to ./data relative to the project root.
|
# Defaults to ./data relative to the project root.
|
||||||
# DATA_DIR=/app/data
|
# DATA_DIR=/app/data
|
||||||
|
|
||||||
|
# Number of days of completed download history to show in the Recently Completed section.
|
||||||
|
# Override per-request with ?days=N (capped at 90).
|
||||||
|
# RECENT_COMPLETED_DAYS=7
|
||||||
|
|
||||||
# Background polling interval in milliseconds (default: 5000)
|
# Background polling interval in milliseconds (default: 5000)
|
||||||
# sofarr polls all services in the background and caches results so
|
# sofarr polls all services in the background and caches results so
|
||||||
# dashboard requests are near-instant.
|
# dashboard requests are near-instant.
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY --chown=root:root server/ ./server/
|
COPY --chown=root:root server/ ./server/
|
||||||
COPY --chown=root:root public/ ./public/
|
COPY --chown=root:root public/ ./public/
|
||||||
COPY --chown=root:root package.json ./
|
COPY --chown=root:root package.json ./
|
||||||
|
# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only).
|
||||||
|
# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars.
|
||||||
|
COPY --chown=root:root certs/ ./certs/
|
||||||
|
|
||||||
# Persistent data directory owned by node user (token store, logs)
|
# Persistent data directory owned by node user (token store, logs)
|
||||||
RUN mkdir -p /app/data && chown node:node /app/data
|
RUN mkdir -p /app/data && chown node:node /app/data
|
||||||
@@ -47,7 +50,9 @@ USER node
|
|||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
|
||||||
# HEALTHCHECK — Docker will restart the container if this fails 3 times
|
# HEALTHCHECK — Docker will restart the container if this fails 3 times
|
||||||
|
# --no-check-certificate handles self-signed / snakeoil certs.
|
||||||
|
# Remove that flag when using a CA-signed certificate.
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD wget -qO- http://localhost:3001/health || exit 1
|
CMD wget -qO- --no-check-certificate https://localhost:3001/health || exit 1
|
||||||
|
|
||||||
CMD ["node", "server/index.js"]
|
CMD ["node", "server/index.js"]
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ sofarr polls all configured services in the background and caches the results. D
|
|||||||
### For qBittorrent Downloads
|
### For qBittorrent Downloads
|
||||||
- **Seeds** - Number of seeders
|
- **Seeds** - Number of seeders
|
||||||
- **Peers** - Number of peers
|
- **Peers** - Number of peers
|
||||||
- **Availability** - Percentage available in swarm
|
- **Availability** - Percentage available in swarm (shown in red when below 100%)
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Ignore all cert/key files EXCEPT the bundled snakeoil development defaults.
|
||||||
|
# Never commit real TLS certificates or private keys to version control.
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
!snakeoil.crt
|
||||||
|
!snakeoil.key
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIDoTCCAomgAwIBAgIUPgupZzf+zgMp4ylvf1NBRIWNpeUwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwUjELMAkGA1UEBhMCR0IxDjAMBgNVBAgMBUxvY2FsMQ4wDAYDVQQHDAVMb2Nh
|
||||||
|
bDEPMA0GA1UECgwGc29mYXJyMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjYwNTE3
|
||||||
|
MDk0NDQ4WhcNMzYwNTE0MDk0NDQ4WjBSMQswCQYDVQQGEwJHQjEOMAwGA1UECAwF
|
||||||
|
TG9jYWwxDjAMBgNVBAcMBUxvY2FsMQ8wDQYDVQQKDAZzb2ZhcnIxEjAQBgNVBAMM
|
||||||
|
CWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL5Y05DF
|
||||||
|
9U3k27SGByJqdbKThRRabX0hsK0zckkbXoaj0U4odZatUicAqkm6yWzpqQyZM6qH
|
||||||
|
XX68LXqJk8r2VIdTTtKe5QU1WUAsW6KD0c514YC1VZ3a9ivQ2/tfdQsm8YxM/ECq
|
||||||
|
e2XFklfvE72HkHF4fM9LCMS/LeazazF0ogNTkyE27g9Ry/ofR2P4MymLtyBbQ1NA
|
||||||
|
B1zYRIhJ5HqFRMszBKhWi1zRgUQQNBuYA5wtxnXA9QNSz6ObtdWStfJ/C1Kuitpe
|
||||||
|
OVrB/TKCp1OKBpZTd4mBKlvdJWos9eZPzP4vLnsReT4pHBx6J2Jd1dC97/MAXLYP
|
||||||
|
mIXP+04zK5sWRUsCAwEAAaNvMG0wHQYDVR0OBBYEFCpYKb3zMgv4qThe8ukFrVIl
|
||||||
|
lhD3MB8GA1UdIwQYMBaAFCpYKb3zMgv4qThe8ukFrVIllhD3MA8GA1UdEwEB/wQF
|
||||||
|
MAMBAf8wGgYDVR0RBBMwEYcEfwAAAYIJbG9jYWxob3N0MA0GCSqGSIb3DQEBCwUA
|
||||||
|
A4IBAQBQ5Jg0pn4XW/560laNl7XAoGuMwrIy5j6zDlIgFE8DWAb0NGNyu/FkCVIZ
|
||||||
|
ruLNktq+w+kTeneAuYW3CGyac2HZo9T60VtQNL/k17hrDw1F6kMpAAYm6aiyFtE9
|
||||||
|
Stw07PMvpvjxKvetPLOQsfk0/hh0Nh2PiVVdqvVE/gGoraboHEfH+eGf/Arzg7s4
|
||||||
|
CzZTEz0OJP5i7VkZAvFygShPx/gHY77ojeHPl2LN3KI7s43TrjYZjxbUDwWDpGp0
|
||||||
|
BIsDFP+9NAvA5I74biChfEEopmBczVbQRtqBxBX1JTa2aT5w/ifUNyKVKIGp49Q8
|
||||||
|
o59gDmbCXhypom7OsyxBLZgyVWU1
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC+WNOQxfVN5Nu0
|
||||||
|
hgcianWyk4UUWm19IbCtM3JJG16Go9FOKHWWrVInAKpJusls6akMmTOqh11+vC16
|
||||||
|
iZPK9lSHU07SnuUFNVlALFuig9HOdeGAtVWd2vYr0Nv7X3ULJvGMTPxAqntlxZJX
|
||||||
|
7xO9h5BxeHzPSwjEvy3ms2sxdKIDU5MhNu4PUcv6H0dj+DMpi7cgW0NTQAdc2ESI
|
||||||
|
SeR6hUTLMwSoVotc0YFEEDQbmAOcLcZ1wPUDUs+jm7XVkrXyfwtSroraXjlawf0y
|
||||||
|
gqdTigaWU3eJgSpb3SVqLPXmT8z+Ly57EXk+KRwceidiXdXQve/zAFy2D5iFz/tO
|
||||||
|
MyubFkVLAgMBAAECggEAEk0RCyNCJBSdqSKWLtFq8o0rdBsDpugyOymuOQHOjOxu
|
||||||
|
oQo6NnWaNF5kgQVAyfd0EpGFfavyw2iNVaUPo1zoU9ogwuOWSnHOj64UIcp0+PN6
|
||||||
|
VHknohWqdukBLyiGfnJM/0ieaKTbi2i7dGsfDA+rxbUoO6s2GYPC6O9inlUqVJGU
|
||||||
|
fBXKTbCneW1+hJQFcOKPW2+qwiUxbudG9neNTYFOm9a7Bfa6MLJ22mkfYVrHYDzo
|
||||||
|
gESo8sHXbEtvemka9jN0N/GoXgUi7iFXbqslPUoakCF7sgG0cXuUM9duSt67ynLj
|
||||||
|
j7Ix+QiKAZgvSO/83b7vK74Xmd6XFUif41VMZ2iEGQKBgQDqp/ZSCAakarRpBRN4
|
||||||
|
psKuhaYiBKurb5GezTOSfEGWxmng2s0bHcx74alKKNN8Tu2mNx6yafQdRNQdM+fG
|
||||||
|
dn8JnQUGXYpGwQbLHZ/aO0M64WBxfQku4JNGh+VZ3ZyUC+So28vzCYqx8qwJi94L
|
||||||
|
2sF8787hzHO1INzVaeXzQ89pEwKBgQDPqRzsJsP+zZmA4x16CtoWY7Z1d4z0GTkA
|
||||||
|
erlXNinu76AIvra6aUld/65ymMyNIIpLZoci55ZGxBY7g8U29e10iT+xmxAgq3VT
|
||||||
|
Tn438MbDXEgA+HvdRXCFPvNQQk0ZDegazdhTdtuhj+IHcm4M94zfVM3MSJOr0hJf
|
||||||
|
JGaVIGEx6QKBgDZLftckPEU221+haQv1qf4vtm0Qn5gfTJZt7Izsa1CzwDPi7Kpl
|
||||||
|
jrbrU/xwzd5pdNuMzXGCypUrI9lN9Ucai/JxfoQmiKQubZ/5zs7z/25UT7hysflC
|
||||||
|
xVEAiLTubhhjWBkqIlqtzoW2HNBoqIwdpb9+zWO5ptw2KmLHCgnrmsY5AoGBAKOt
|
||||||
|
YCaix4lm9L8qRGmVdCCBp6ce+/LKjqtaEAw1nQe/yBwcdlqn8jQs+4tH9LKoG1kj
|
||||||
|
DxDsCP7uP7fZPPD9FpTsOU/8MNIPUwK+s63UElaZvgdF1BusR+w+mfmAyNQeqfu2
|
||||||
|
k/P1k1fc2QOVpjiCRn8hkLSb4AlmIyTqxBB23SVBAoGATMtuk1r+SS9SDJW73CI1
|
||||||
|
jLkJkPGrBvX0dFZGtedpwLVhUTDnqKIsy2F1iGDRGIAy5IAiLEFx44qQrhUau7zR
|
||||||
|
/2avY0Ua9To9LW0k/matzJUKvXxYm59jh/GDp6LFU89hsL2zSd6z0Aknvh1SryCb
|
||||||
|
OSbN8wfCz53+7qea4NQEB4E=
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
@@ -4,14 +4,23 @@ services:
|
|||||||
container_name: sofarr
|
container_name: sofarr
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3001:3001" # bind to loopback only — expose via reverse proxy
|
# Direct HTTPS (default — uses bundled snakeoil cert if TLS_CERT not set)
|
||||||
|
- "3001:3001"
|
||||||
|
# Uncomment the line below and comment out the above to bind to loopback
|
||||||
|
# only when using a reverse proxy (set TLS_ENABLED=false in that case):
|
||||||
|
# - "127.0.0.1:3001:3001"
|
||||||
environment:
|
environment:
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- LOG_LEVEL=info
|
- LOG_LEVEL=info
|
||||||
# Set to 1 when running behind a reverse proxy (Nginx, Caddy, Traefik)
|
# --- TLS ---
|
||||||
# so Express trusts X-Forwarded-For and X-Forwarded-Proto headers.
|
# Default: TLS enabled using bundled snakeoil cert (self-signed).
|
||||||
- TRUST_PROXY=1
|
# Supply your own cert/key by mounting them and setting these paths:
|
||||||
|
# - TLS_CERT=/app/certs/server.crt
|
||||||
|
# - TLS_KEY=/app/certs/server.key
|
||||||
|
# Set TLS_ENABLED=false if terminating TLS at a reverse proxy instead.
|
||||||
|
# If using a reverse proxy, also set TRUST_PROXY=1 below.
|
||||||
|
# - TRUST_PROXY=1
|
||||||
# --- Replace placeholders with real values or use Docker secrets ---
|
# --- Replace placeholders with real values or use Docker secrets ---
|
||||||
- COOKIE_SECRET=change-me-generate-with-openssl-rand-hex-32
|
- COOKIE_SECRET=change-me-generate-with-openssl-rand-hex-32
|
||||||
- EMBY_URL=https://emby.example.com
|
- EMBY_URL=https://emby.example.com
|
||||||
@@ -21,8 +30,11 @@ services:
|
|||||||
- SABNZBD_INSTANCES=[{"name":"main","url":"https://sabnzbd.example.com","apiKey":"your-sabnzbd-api-key"}]
|
- SABNZBD_INSTANCES=[{"name":"main","url":"https://sabnzbd.example.com","apiKey":"your-sabnzbd-api-key"}]
|
||||||
- QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"your-password"}]
|
- QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"your-password"}]
|
||||||
volumes:
|
volumes:
|
||||||
# Persistent volume for SQLite token store and log file
|
# Persistent volume for token store and log file
|
||||||
- sofarr-data:/app/data
|
- sofarr-data:/app/data
|
||||||
|
# Mount your own TLS certificate and key (optional — snakeoil used if omitted)
|
||||||
|
# - /path/to/your/server.crt:/app/certs/server.crt:ro
|
||||||
|
# - /path/to/your/server.key:/app/certs/server.key:ro
|
||||||
# Run as the built-in non-root 'node' user (UID/GID 1000)
|
# Run as the built-in non-root 'node' user (UID/GID 1000)
|
||||||
user: "1000:1000"
|
user: "1000:1000"
|
||||||
# Read-only root filesystem; only the data volume is writable
|
# Read-only root filesystem; only the data volume is writable
|
||||||
@@ -35,7 +47,9 @@ services:
|
|||||||
- ALL # drop all Linux capabilities
|
- ALL # drop all Linux capabilities
|
||||||
cap_add: [] # add back none — Node.js needs no special caps
|
cap_add: [] # add back none — Node.js needs no special caps
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"]
|
# Uses --no-check-certificate for self-signed / snakeoil certs.
|
||||||
|
# Remove that flag if using a CA-signed certificate.
|
||||||
|
test: ["CMD", "wget", "-qO-", "--no-check-certificate", "https://localhost:3001/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
|
After Width: | Height: | Size: 331 KiB |
@@ -1,156 +0,0 @@
|
|||||||
@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
|
|
||||||
|
After Width: | Height: | Size: 304 KiB |
@@ -1,230 +0,0 @@
|
|||||||
@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
|
|
||||||
|
After Width: | Height: | Size: 473 KiB |
@@ -1,278 +0,0 @@
|
|||||||
@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
|
|
||||||
--
|
|
||||||
Logging setup, app.listen(),
|
|
||||||
static files, startPoller()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
package "server/app.js" as appfactory {
|
|
||||||
class "createApp(options?)" as appfn <<factory>> {
|
|
||||||
+ createApp(skipRateLimits?) : Express
|
|
||||||
--
|
|
||||||
Mounts helmet (CSP nonce),
|
|
||||||
rate limiters, cookie-parser,
|
|
||||||
auth routes (pre-CSRF),
|
|
||||||
verifyCsrf, all other routes,
|
|
||||||
/health, /ready, error handler
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
package "server/routes" {
|
|
||||||
class "auth.js" as auth <<router>> {
|
|
||||||
+ POST /login (rate-limited)
|
|
||||||
+ GET /me
|
|
||||||
+ GET /csrf
|
|
||||||
+ POST /logout
|
|
||||||
--
|
|
||||||
Authenticates via Emby API
|
|
||||||
Issues emby_user + csrf_token cookies
|
|
||||||
Stores/revokes Emby tokens server-side
|
|
||||||
}
|
|
||||||
|
|
||||||
class "dashboard.js" as dashboard <<router>> {
|
|
||||||
- activeClients : Map<string, ClientInfo>
|
|
||||||
- CLIENT_STALE_MS : 30000
|
|
||||||
--
|
|
||||||
+ GET /stream (SSE, text/event-stream)
|
|
||||||
+ GET /user-downloads
|
|
||||||
+ GET /user-summary
|
|
||||||
+ GET /status
|
|
||||||
+ GET /cover-art
|
|
||||||
--
|
|
||||||
- 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 (signed if COOKIE_SECRET)
|
|
||||||
Validates schema: id, name, isAdmin
|
|
||||||
Attaches user to req.user
|
|
||||||
Returns 401 if absent/tampered/invalid
|
|
||||||
}
|
|
||||||
|
|
||||||
class "verifyCsrf.js" as verifycsrf <<middleware>> {
|
|
||||||
+ verifyCsrf(req, res, next) : void
|
|
||||||
--
|
|
||||||
Exempt: GET, HEAD, OPTIONS
|
|
||||||
Compares csrf_token cookie
|
|
||||||
vs X-CSRF-Token header
|
|
||||||
using crypto.timingSafeEqual
|
|
||||||
Returns 403 on mismatch/missing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 "TokenStore" as tokenstore <<module>> {
|
|
||||||
- store : Object (in-memory)
|
|
||||||
- STORE_PATH : string (DATA_DIR/tokens.json)
|
|
||||||
- TOKEN_TTL_MS : 31 days
|
|
||||||
--
|
|
||||||
+ storeToken(userId, accessToken) : void
|
|
||||||
+ getToken(userId) : {accessToken}|null
|
|
||||||
+ clearToken(userId) : void
|
|
||||||
--
|
|
||||||
Atomic write (.tmp → rename)
|
|
||||||
Pruned on startup + hourly
|
|
||||||
}
|
|
||||||
|
|
||||||
class "SanitizeError" as sanitize <<module>> {
|
|
||||||
+ sanitizeError(err) : string
|
|
||||||
--
|
|
||||||
Redacts: query-param secrets,
|
|
||||||
auth headers, bearer tokens,
|
|
||||||
basic-auth URLs
|
|
||||||
}
|
|
||||||
|
|
||||||
class "TagBadge" as tb <<value>> {
|
|
||||||
+ label : string
|
|
||||||
+ matchedUser : string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
class "ClientInfo" as ci <<value>> {
|
|
||||||
+ user : string
|
|
||||||
+ type : 'sse'
|
|
||||||
+ connectedAt : number (timestamp)
|
|
||||||
+ lastSeen : number (timestamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
' Relationships
|
|
||||||
ep --> appfn : createApp()
|
|
||||||
ep --> poller : startPoller()
|
|
||||||
|
|
||||||
appfn --> auth : /api/auth (pre-CSRF)
|
|
||||||
appfn --> verifycsrf : /api (all routes below)
|
|
||||||
appfn --> dashboard
|
|
||||||
appfn --> emby_r
|
|
||||||
appfn --> sab_r
|
|
||||||
appfn --> sonarr_r
|
|
||||||
appfn --> radarr_r
|
|
||||||
|
|
||||||
dashboard --> requireauth : uses
|
|
||||||
emby_r --> requireauth : uses
|
|
||||||
sab_r --> requireauth : uses
|
|
||||||
sonarr_r --> requireauth : uses
|
|
||||||
radarr_r --> requireauth : uses
|
|
||||||
|
|
||||||
auth --> tokenstore : storeToken / getToken / clearToken
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
auth ..> sanitize : sanitizeError on catch
|
|
||||||
dashboard ..> sanitize : sanitizeError on catch
|
|
||||||
|
|
||||||
@enduml
|
|
||||||
|
After Width: | Height: | Size: 297 KiB |
@@ -1,118 +0,0 @@
|
|||||||
@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 {
|
|
||||||
|
|
||||||
[index.js\nEntry Point] as entry
|
|
||||||
[app.js\ncreatApp() factory] as appfactory
|
|
||||||
|
|
||||||
package "Middleware" {
|
|
||||||
[helmet\n(CSP nonce, HSTS)] as hm
|
|
||||||
[express-rate-limit\n(API + login)] as rl
|
|
||||||
[cookie-parser\n(signed cookies)] as cp
|
|
||||||
[express.json\n(64kb limit)] as ej
|
|
||||||
[express.static] as es
|
|
||||||
[requireAuth.js] as requireauth
|
|
||||||
[verifyCsrf.js\n(double-submit)] as verifycsrf
|
|
||||||
}
|
|
||||||
|
|
||||||
package "Routes" as routes {
|
|
||||||
[auth.js\n/api/auth\n(pre-CSRF)] as auth
|
|
||||||
[dashboard.js\n/api/dashboard\n(+SSE /stream)] 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
|
|
||||||
[tokenStore.js\n(tokens.json)] as tokenstore
|
|
||||||
[sanitizeError.js] as sanitize
|
|
||||||
[logger.js] as logger
|
|
||||||
}
|
|
||||||
|
|
||||||
entry --> appfactory : createApp()
|
|
||||||
entry --> es : serve public/
|
|
||||||
entry --> poller : startPoller()
|
|
||||||
|
|
||||||
appfactory --> hm
|
|
||||||
appfactory --> rl
|
|
||||||
appfactory --> cp
|
|
||||||
appfactory --> ej
|
|
||||||
appfactory --> auth : mount before verifyCsrf
|
|
||||||
appfactory --> verifycsrf : applied to all /api below
|
|
||||||
appfactory --> dashboard
|
|
||||||
appfactory --> emby_route
|
|
||||||
appfactory --> sab_route
|
|
||||||
appfactory --> sonarr_route
|
|
||||||
appfactory --> radarr_route
|
|
||||||
|
|
||||||
emby_route --> requireauth
|
|
||||||
sab_route --> requireauth
|
|
||||||
sonarr_route --> requireauth
|
|
||||||
radarr_route --> requireauth
|
|
||||||
dashboard --> requireauth
|
|
||||||
|
|
||||||
auth --> tokenstore : storeToken / getToken / clearToken
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
auth ..> sanitize
|
|
||||||
dashboard ..> sanitize
|
|
||||||
|
|
||||||
note "Browser uses EventSource\n(SSE) to /stream.\nServer pushes on each\npoll cycle — no client timer." as sseNote
|
|
||||||
sseNote .. dashboard
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
After Width: | Height: | Size: 247 KiB |
@@ -1,104 +0,0 @@
|
|||||||
@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 "TokenStore\n(tokens.json)" as tokens
|
|
||||||
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\n(signed if COOKIE_SECRET set)
|
|
||||||
alt Cookie exists and valid
|
|
||||||
auth --> browser : { authenticated: true, user: { name, isAdmin } }
|
|
||||||
browser -> auth : GET /api/auth/csrf
|
|
||||||
activate auth
|
|
||||||
auth -> auth : Generate 32-byte hex csrfToken
|
|
||||||
auth --> browser : { csrfToken } + Set csrf_token cookie
|
|
||||||
deactivate auth
|
|
||||||
browser -> browser : store csrfToken in memory
|
|
||||||
browser -> browser : showDashboard()
|
|
||||||
browser -> browser : startAutoRefresh()
|
|
||||||
browser -> browser : dismissSplash()
|
|
||||||
else No cookie / tampered
|
|
||||||
auth --> browser : { authenticated: false }
|
|
||||||
browser -> browser : dismissSplash()
|
|
||||||
browser -> browser : showLogin()
|
|
||||||
end
|
|
||||||
deactivate auth
|
|
||||||
|
|
||||||
== Login ==
|
|
||||||
user -> browser : Enter username + password\n(+ optional rememberMe checkbox)
|
|
||||||
browser -> auth : POST /api/auth/login\n{ username, password, rememberMe }
|
|
||||||
activate auth
|
|
||||||
note right of auth
|
|
||||||
Rate limiter: max 10 failed
|
|
||||||
attempts per IP / 15 min
|
|
||||||
(successful requests excluded)
|
|
||||||
end note
|
|
||||||
auth -> emby : POST /Users/authenticatebyname\n{ Username, Pw }\nX-Emby-Authorization: MediaBrowser\nDeviceId = sha256(username)[0:16]
|
|
||||||
activate emby
|
|
||||||
alt Valid credentials
|
|
||||||
emby --> auth : { User: { Id }, AccessToken }
|
|
||||||
auth -> emby : GET /Users/{userId}\nX-MediaBrowser-Token: AccessToken
|
|
||||||
emby --> auth : { Name, Policy: { IsAdministrator } }
|
|
||||||
deactivate emby
|
|
||||||
auth -> tokens : storeToken(userId, AccessToken)
|
|
||||||
note right of tokens
|
|
||||||
Stored server-side only.
|
|
||||||
Never sent to the client.
|
|
||||||
31-day TTL, atomic JSON write.
|
|
||||||
end note
|
|
||||||
auth -> auth : Set emby_user cookie\n{ id, name, isAdmin }\nhttpOnly, sameSite=strict\nsecure (prod), signed (COOKIE_SECRET)\nrememberMe=true → Max-Age 30d\nrememberMe=false → session cookie
|
|
||||||
auth -> auth : Generate csrfToken\n(32-byte random hex)
|
|
||||||
auth -> auth : Set csrf_token cookie\nhttpOnly=false (JS-readable)\nsameSite=strict, secure (prod)
|
|
||||||
auth --> browser : { success: true, user, csrfToken }
|
|
||||||
browser -> browser : store csrfToken in memory
|
|
||||||
browser -> browser : fadeOutLogin()
|
|
||||||
browser -> browser : showDashboard()
|
|
||||||
browser -> browser : startAutoRefresh()
|
|
||||||
browser -> browser : dismissSplash()
|
|
||||||
else Invalid credentials
|
|
||||||
emby --> auth : 401 Error
|
|
||||||
deactivate emby
|
|
||||||
auth --> browser : { success: false, error: "Invalid username or password" }
|
|
||||||
browser -> browser : showLoginError()
|
|
||||||
end
|
|
||||||
deactivate auth
|
|
||||||
|
|
||||||
== CSRF Token Refresh (after page reload) ==
|
|
||||||
note over browser : csrfToken lost from memory\non hard page reload
|
|
||||||
browser -> auth : GET /api/auth/csrf
|
|
||||||
activate auth
|
|
||||||
auth -> auth : Generate new csrfToken
|
|
||||||
auth --> browser : { csrfToken } + new csrf_token cookie
|
|
||||||
deactivate auth
|
|
||||||
browser -> browser : store new csrfToken in memory
|
|
||||||
|
|
||||||
== Logout ==
|
|
||||||
user -> browser : Click Logout
|
|
||||||
browser -> browser : stopAutoRefresh()
|
|
||||||
browser -> auth : POST /api/auth/logout\n(no CSRF required — auth routes\nexempt; sameSite:strict protects)
|
|
||||||
activate auth
|
|
||||||
auth -> auth : Parse emby_user cookie → user
|
|
||||||
auth -> tokens : getToken(user.id)
|
|
||||||
activate tokens
|
|
||||||
tokens --> auth : { accessToken }
|
|
||||||
deactivate tokens
|
|
||||||
auth -> emby : POST /Sessions/Logout\nX-MediaBrowser-Token: accessToken
|
|
||||||
activate emby
|
|
||||||
emby --> auth : 204 / error (ignored)
|
|
||||||
deactivate emby
|
|
||||||
auth -> tokens : clearToken(user.id)
|
|
||||||
auth -> auth : clearCookie(emby_user)\nclearCookie(csrf_token)
|
|
||||||
auth --> browser : { success: true }
|
|
||||||
deactivate auth
|
|
||||||
browser -> browser : showLogin()
|
|
||||||
|
|
||||||
deactivate browser
|
|
||||||
@enduml
|
|
||||||
|
After Width: | Height: | Size: 161 KiB |
@@ -1,67 +0,0 @@
|
|||||||
@startuml seq-dashboard
|
|
||||||
!theme plain
|
|
||||||
title sofarr — Dashboard SSE Stream 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
|
|
||||||
|
|
||||||
== SSE Connection (on login / page load) ==
|
|
||||||
user -> browser : Login success\nor valid session
|
|
||||||
activate browser
|
|
||||||
browser -> dashboard : GET /api/dashboard/stream\n(EventSource, Cookie: emby_user)
|
|
||||||
activate dashboard
|
|
||||||
|
|
||||||
dashboard -> dashboard : requireAuth: parse cookie\nextract username, isAdmin
|
|
||||||
dashboard -> dashboard : Set headers:\nContent-Type: text/event-stream\nX-Accel-Buffering: no
|
|
||||||
dashboard -> dashboard : Register in activeClients Map\n{ user, type:'sse', connectedAt }
|
|
||||||
|
|
||||||
alt Polling disabled AND cache empty
|
|
||||||
dashboard -> poller : pollAllServices()
|
|
||||||
activate poller
|
|
||||||
poller -> ext : Parallel API calls
|
|
||||||
ext --> poller : Raw data
|
|
||||||
poller -> cache : set poll:* keys (TTL=30s)
|
|
||||||
deactivate poller
|
|
||||||
end
|
|
||||||
|
|
||||||
== Initial Payload (sent immediately on connect) ==
|
|
||||||
dashboard -> cache : get all poll:* keys
|
|
||||||
dashboard -> dashboard : Build seriesMap, moviesMap,\nsonarrTagMap, radarrTagMap
|
|
||||||
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
|
|
||||||
end
|
|
||||||
dashboard -> dashboard : Match SABnzbd/qBit downloads\nvs Sonarr/Radarr records\nextractUserTag / buildTagBadges
|
|
||||||
dashboard --> browser : data: { user, isAdmin, downloads }
|
|
||||||
browser -> browser : hideLoading()\nrenderDownloads()
|
|
||||||
|
|
||||||
== Pushed Updates (on every poll cycle) ==
|
|
||||||
loop Each poll cycle completes
|
|
||||||
poller -> poller : pollAllServices() complete
|
|
||||||
poller -> dashboard : onPollComplete callback fires
|
|
||||||
dashboard -> cache : get all poll:* keys
|
|
||||||
dashboard -> dashboard : Rebuild download payload
|
|
||||||
dashboard --> browser : data: { user, isAdmin, downloads }
|
|
||||||
browser -> browser : renderDownloads() (diff-based)
|
|
||||||
end
|
|
||||||
|
|
||||||
== Heartbeat (every 25s) ==
|
|
||||||
dashboard --> browser : : heartbeat
|
|
||||||
note right : Keeps connection alive\nthrough idle-timeout proxies
|
|
||||||
|
|
||||||
== Client Disconnects ==
|
|
||||||
user -> browser : Close tab / logout
|
|
||||||
browser -> dashboard : TCP close (req 'close' event)
|
|
||||||
dashboard -> dashboard : offPollComplete(callback)\nclearInterval(heartbeat)\ndelete activeClients[key]
|
|
||||||
deactivate dashboard
|
|
||||||
deactivate browser
|
|
||||||
|
|
||||||
@enduml
|
|
||||||
|
After Width: | Height: | Size: 206 KiB |
@@ -1,93 +0,0 @@
|
|||||||
@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 : Notify SSE subscribers\npollSubscribers.forEach(cb => cb())
|
|
||||||
|
|
||||||
note over poller : Each registered SSE client\ncallback rebuilds its payload\nand writes a data: frame
|
|
||||||
|
|
||||||
poller -> poller : polling = false\nlog elapsed time
|
|
||||||
|
|
||||||
deactivate poller
|
|
||||||
|
|
||||||
@enduml
|
|
||||||
|
After Width: | Height: | Size: 131 KiB |
@@ -1,67 +0,0 @@
|
|||||||
@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 --> notifying : Cache updated
|
|
||||||
state "Notifying SSE\nsubscribers" as notifying
|
|
||||||
notifying --> 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
|
|
||||||
|
After Width: | Height: | Size: 139 KiB |
@@ -1,73 +0,0 @@
|
|||||||
@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 "startSSE() — awaiting\nfirst SSE message" as fetching
|
|
||||||
}
|
|
||||||
|
|
||||||
SplashScreen2 --> Dashboard : Data loaded\ndismissSplash()
|
|
||||||
|
|
||||||
state Dashboard {
|
|
||||||
state "Rendering Cards" as rendering
|
|
||||||
state "Status Panel Open" as status_open
|
|
||||||
state "Status Panel Closed" as status_closed
|
|
||||||
|
|
||||||
[*] --> rendering
|
|
||||||
rendering --> rendering : SSE message received
|
|
||||||
→ renderDownloads()
|
|
||||||
rendering --> rendering : Theme change
|
|
||||||
|
|
||||||
status_closed --> status_open : Click "Status" btn
|
|
||||||
(admin only)
|
|
||||||
status_open --> status_closed : Click close (×)
|
|
||||||
status_open --> status_open : 5s timer
|
|
||||||
→ renderStatusPanel()
|
|
||||||
|
|
||||||
[*] --> status_closed
|
|
||||||
|
|
||||||
state "SSE Connection" as sse {
|
|
||||||
state "Connecting" as sc
|
|
||||||
state "Connected" as scon
|
|
||||||
state "Reconnecting" as srec
|
|
||||||
sc --> scon : First message received
|
|
||||||
scon --> srec : Connection lost
|
|
||||||
srec --> scon : Browser auto-reconnects
|
|
||||||
scon --> sc : showAll toggle changed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Dashboard --> LoginForm : Logout
|
|
||||||
(stopSSE,
|
|
||||||
clear state)
|
|
||||||
|
|
||||||
@enduml
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ let showAll = false;
|
|||||||
let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
|
let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
|
||||||
const SPLASH_MIN_MS = 1200; // minimum splash display time
|
const SPLASH_MIN_MS = 1200; // minimum splash display time
|
||||||
|
|
||||||
|
// History section state
|
||||||
|
let historyDays = parseInt(localStorage.getItem('sofarr-history-days'), 10) || 7;
|
||||||
|
let historyRefreshHandle = null;
|
||||||
|
const HISTORY_REFRESH_MS = 5 * 60 * 1000; // auto-refresh history every 5 min
|
||||||
|
|
||||||
// SSE stream state
|
// SSE stream state
|
||||||
let sseSource = null;
|
let sseSource = null;
|
||||||
let sseReconnectTimer = null;
|
let sseReconnectTimer = null;
|
||||||
@@ -20,6 +25,8 @@ const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit b
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
checkAuthentication();
|
checkAuthentication();
|
||||||
initThemeSwitcher();
|
initThemeSwitcher();
|
||||||
|
initTabs();
|
||||||
|
initHistoryControls();
|
||||||
|
|
||||||
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
document.getElementById('login-form').addEventListener('submit', handleLogin);
|
||||||
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
document.getElementById('logout-btn').addEventListener('click', handleLogout);
|
||||||
@@ -43,6 +50,30 @@ function setTheme(theme) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initTabs() {
|
||||||
|
const savedTab = localStorage.getItem('sofarr-active-tab') || 'downloads';
|
||||||
|
activateTab(savedTab, false);
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const tab = btn.dataset.tab;
|
||||||
|
activateTab(tab, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateTab(tabName, save) {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(panel => {
|
||||||
|
panel.style.display = panel.id === `tab-${tabName}` ? '' : 'none';
|
||||||
|
});
|
||||||
|
if (save) localStorage.setItem('sofarr-active-tab', tabName);
|
||||||
|
// Load history the first time the history tab is shown
|
||||||
|
if (tabName === 'history') loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
// --- SSE connection management ---
|
// --- SSE connection management ---
|
||||||
|
|
||||||
function startSSE() {
|
function startSSE() {
|
||||||
@@ -88,6 +119,8 @@ function handleShowAllToggle(e) {
|
|||||||
showAll = e.target.checked;
|
showAll = e.target.checked;
|
||||||
// Re-open stream with updated showAll param
|
// Re-open stream with updated showAll param
|
||||||
startSSE();
|
startSSE();
|
||||||
|
// Reload history with updated showAll param
|
||||||
|
loadHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fadeOutLogin() {
|
function fadeOutLogin() {
|
||||||
@@ -209,6 +242,7 @@ async function handleLogin(e) {
|
|||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
stopSSE();
|
stopSSE();
|
||||||
|
stopHistoryRefresh();
|
||||||
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
|
||||||
await fetch('/api/auth/logout', {
|
await fetch('/api/auth/logout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -217,6 +251,7 @@ async function handleLogout() {
|
|||||||
currentUser = null;
|
currentUser = null;
|
||||||
csrfToken = null;
|
csrfToken = null;
|
||||||
downloads = [];
|
downloads = [];
|
||||||
|
clearHistory();
|
||||||
showLogin();
|
showLogin();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Logout failed:', err);
|
console.error('Logout failed:', err);
|
||||||
@@ -238,6 +273,10 @@ function showDashboard() {
|
|||||||
sp.style.display = 'none';
|
sp.style.display = 'none';
|
||||||
sp.innerHTML = '';
|
sp.innerHTML = '';
|
||||||
document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none';
|
document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none';
|
||||||
|
// Initialise days input from saved value
|
||||||
|
const daysInput = document.getElementById('history-days');
|
||||||
|
if (daysInput) daysInput.value = historyDays;
|
||||||
|
startHistoryRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLoginError(message) {
|
function showLoginError(message) {
|
||||||
@@ -251,6 +290,30 @@ function hideLoginError() {
|
|||||||
errorDiv.style.display = 'none';
|
errorDiv.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build an episode-info element for series downloads/history.
|
||||||
|
// Single episode: "S01E05 — Episode Title"
|
||||||
|
// Multiple episodes: "Multiple episodes" with tooltip listing them all.
|
||||||
|
// Returns null if no episode data.
|
||||||
|
function formatEpisodeInfo(episodes) {
|
||||||
|
if (!episodes || episodes.length === 0) return null;
|
||||||
|
const el = document.createElement('p');
|
||||||
|
el.className = 'episode-info';
|
||||||
|
if (episodes.length === 1) {
|
||||||
|
const ep = episodes[0];
|
||||||
|
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
|
||||||
|
el.textContent = ep.title ? code + ' \u2014 ' + ep.title : code;
|
||||||
|
} else {
|
||||||
|
el.textContent = 'Multiple episodes';
|
||||||
|
el.classList.add('multi-episode');
|
||||||
|
const lines = episodes.map(ep => {
|
||||||
|
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
|
||||||
|
return ep.title ? code + ' \u2014 ' + ep.title : code;
|
||||||
|
});
|
||||||
|
el.setAttribute('data-tooltip', lines.join('\n'));
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
// fetchUserDownloads is kept for the showAll toggle re-connection case
|
// fetchUserDownloads is kept for the showAll toggle re-connection case
|
||||||
// but the primary data path is now via SSE (startSSE / EventSource).
|
// but the primary data path is now via SSE (startSSE / EventSource).
|
||||||
|
|
||||||
@@ -359,9 +422,10 @@ function updateDownloadCard(card, download) {
|
|||||||
peersEl.textContent = download.peers;
|
peersEl.textContent = download.peers;
|
||||||
}
|
}
|
||||||
|
|
||||||
const availabilityEl = card.querySelector('.detail-item[data-label="Availability"] .detail-value');
|
const availabilityItem = card.querySelector('.detail-item[data-label="Availability"]');
|
||||||
if (availabilityEl && download.availability !== undefined) {
|
if (availabilityItem && download.availability !== undefined) {
|
||||||
availabilityEl.textContent = `${download.availability}%`;
|
availabilityItem.querySelector('.detail-value').textContent = `${download.availability}%`;
|
||||||
|
availabilityItem.classList.toggle('availability-warning', parseFloat(download.availability) < 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -438,6 +502,8 @@ function createDownloadCard(download) {
|
|||||||
series.textContent = `Series: ${download.seriesName}`;
|
series.textContent = `Series: ${download.seriesName}`;
|
||||||
}
|
}
|
||||||
infoDiv.appendChild(series);
|
infoDiv.appendChild(series);
|
||||||
|
const epEl = formatEpisodeInfo(download.episodes);
|
||||||
|
if (epEl) infoDiv.appendChild(epEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (download.movieName) {
|
if (download.movieName) {
|
||||||
@@ -558,6 +624,7 @@ function createDownloadCard(download) {
|
|||||||
|
|
||||||
if (download.availability !== undefined) {
|
if (download.availability !== undefined) {
|
||||||
const availability = createDetailItem('Availability', `${download.availability}%`);
|
const availability = createDetailItem('Availability', `${download.availability}%`);
|
||||||
|
if (parseFloat(download.availability) < 100) availability.classList.add('availability-warning');
|
||||||
details.appendChild(availability);
|
details.appendChild(availability);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -782,3 +849,199 @@ function hideLoading() {
|
|||||||
const loading = document.getElementById('loading');
|
const loading = document.getElementById('loading');
|
||||||
loading.style.display = 'none';
|
loading.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// History section
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function initHistoryControls() {
|
||||||
|
const daysInput = document.getElementById('history-days');
|
||||||
|
const refreshBtn = document.getElementById('history-refresh-btn');
|
||||||
|
if (daysInput) {
|
||||||
|
daysInput.addEventListener('change', () => {
|
||||||
|
const v = parseInt(daysInput.value, 10);
|
||||||
|
if (v > 0 && v <= 90) {
|
||||||
|
historyDays = v;
|
||||||
|
localStorage.setItem('sofarr-history-days', v);
|
||||||
|
loadHistory();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.addEventListener('click', () => loadHistory(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHistoryRefresh() {
|
||||||
|
stopHistoryRefresh();
|
||||||
|
historyRefreshHandle = setInterval(() => loadHistory(), HISTORY_REFRESH_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHistoryRefresh() {
|
||||||
|
if (historyRefreshHandle) {
|
||||||
|
clearInterval(historyRefreshHandle);
|
||||||
|
historyRefreshHandle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHistory() {
|
||||||
|
document.getElementById('history-list').innerHTML = '';
|
||||||
|
document.getElementById('no-history').style.display = 'none';
|
||||||
|
document.getElementById('history-error').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistory(forceRefresh = false) {
|
||||||
|
const listEl = document.getElementById('history-list');
|
||||||
|
const loadingEl = document.getElementById('history-loading');
|
||||||
|
const errorEl = document.getElementById('history-error');
|
||||||
|
const noHistoryEl = document.getElementById('no-history');
|
||||||
|
|
||||||
|
loadingEl.style.display = 'block';
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
noHistoryEl.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ days: historyDays });
|
||||||
|
if (showAll) params.set('showAll', 'true');
|
||||||
|
if (forceRefresh) params.set('_t', Date.now());
|
||||||
|
const res = await fetch(`/api/history/recent?${params}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
renderHistory(data.history || []);
|
||||||
|
} catch (err) {
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
errorEl.textContent = 'Failed to load history.';
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
console.error('[History] Load error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHistory(items) {
|
||||||
|
const listEl = document.getElementById('history-list');
|
||||||
|
const noHistoryEl = document.getElementById('no-history');
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
if (!items.length) {
|
||||||
|
noHistoryEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
noHistoryEl.style.display = 'none';
|
||||||
|
items.forEach(item => listEl.appendChild(createHistoryCard(item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHistoryCard(item) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = `history-card ${item.type} ${item.outcome}`;
|
||||||
|
|
||||||
|
if (item.coverArt) {
|
||||||
|
const coverDiv = document.createElement('div');
|
||||||
|
coverDiv.className = 'history-cover';
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = '/api/dashboard/cover-art?url=' + encodeURIComponent(item.coverArt);
|
||||||
|
img.alt = item.movieName || item.seriesName || item.title;
|
||||||
|
img.loading = 'lazy';
|
||||||
|
coverDiv.appendChild(img);
|
||||||
|
card.appendChild(coverDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'history-info';
|
||||||
|
|
||||||
|
// Header row: type badge + outcome badge
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'history-card-header';
|
||||||
|
|
||||||
|
const typeBadge = document.createElement('span');
|
||||||
|
typeBadge.className = `history-type-badge ${item.type}`;
|
||||||
|
typeBadge.textContent = item.type === 'series' ? '📺 Series' : '🎬 Movie';
|
||||||
|
header.appendChild(typeBadge);
|
||||||
|
|
||||||
|
const outcomeBadge = document.createElement('span');
|
||||||
|
outcomeBadge.className = `history-outcome-badge ${item.outcome}`;
|
||||||
|
outcomeBadge.textContent = item.outcome === 'imported' ? '✓ Imported' : '✗ Failed';
|
||||||
|
header.appendChild(outcomeBadge);
|
||||||
|
|
||||||
|
if (item.instanceName) {
|
||||||
|
const instBadge = document.createElement('span');
|
||||||
|
instBadge.className = 'history-instance-badge';
|
||||||
|
instBadge.textContent = item.instanceName;
|
||||||
|
header.appendChild(instBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAll && item.tagBadges && item.tagBadges.length > 0) {
|
||||||
|
const unmatched = item.tagBadges.filter(b => !b.matchedUser);
|
||||||
|
const matched = item.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 (item.matchedUserTag) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'download-user-badge';
|
||||||
|
badge.textContent = item.matchedUserTag;
|
||||||
|
header.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
info.appendChild(header);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const title = document.createElement('h3');
|
||||||
|
title.className = 'history-title';
|
||||||
|
title.textContent = item.title;
|
||||||
|
info.appendChild(title);
|
||||||
|
|
||||||
|
// Series/movie name with optional arr link
|
||||||
|
if (item.seriesName) {
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'history-media-name';
|
||||||
|
if (isAdmin && item.arrLink) {
|
||||||
|
p.innerHTML = 'Series: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.seriesName) + '</a>';
|
||||||
|
} else {
|
||||||
|
p.textContent = 'Series: ' + item.seriesName;
|
||||||
|
}
|
||||||
|
info.appendChild(p);
|
||||||
|
const epEl = formatEpisodeInfo(item.episodes);
|
||||||
|
if (epEl) info.appendChild(epEl);
|
||||||
|
}
|
||||||
|
if (item.movieName) {
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'history-media-name';
|
||||||
|
if (isAdmin && item.arrLink) {
|
||||||
|
p.innerHTML = 'Movie: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.movieName) + '</a>';
|
||||||
|
} else {
|
||||||
|
p.textContent = 'Movie: ' + item.movieName;
|
||||||
|
}
|
||||||
|
info.appendChild(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail pills
|
||||||
|
const details = document.createElement('div');
|
||||||
|
details.className = 'history-details';
|
||||||
|
|
||||||
|
if (item.completedAt) {
|
||||||
|
details.appendChild(createDetailItem('Completed', formatDate(item.completedAt)));
|
||||||
|
}
|
||||||
|
if (item.quality) {
|
||||||
|
details.appendChild(createDetailItem('Quality', item.quality));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed imports: show failure message
|
||||||
|
if (item.outcome === 'failed' && item.failureMessage) {
|
||||||
|
const failItem = document.createElement('div');
|
||||||
|
failItem.className = 'history-failure-message';
|
||||||
|
failItem.textContent = item.failureMessage;
|
||||||
|
details.appendChild(failItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
info.appendChild(details);
|
||||||
|
card.appendChild(info);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,13 +74,40 @@
|
|||||||
|
|
||||||
<div id="loading" class="loading" style="display: none;">Loading downloads...</div>
|
<div id="loading" class="loading" style="display: none;">Loading downloads...</div>
|
||||||
|
|
||||||
<div class="downloads-container">
|
<div class="main-tabs">
|
||||||
<h2>Your Downloads</h2>
|
<div class="tab-bar">
|
||||||
<div id="no-downloads" class="no-downloads" style="display: none;">
|
<button class="tab-btn active" data-tab="downloads">Active Downloads</button>
|
||||||
<p>No downloads found for your user.</p>
|
<button class="tab-btn" data-tab="history">Recently Completed</button>
|
||||||
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="tab-downloads">
|
||||||
|
<div class="downloads-container">
|
||||||
|
<div id="no-downloads" class="no-downloads" style="display: none;">
|
||||||
|
<p>No downloads found for your user.</p>
|
||||||
|
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
|
||||||
|
</div>
|
||||||
|
<div id="downloads-list" class="downloads-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="tab-history" style="display: none;">
|
||||||
|
<div class="history-container" id="history-container">
|
||||||
|
<div class="history-header">
|
||||||
|
<div class="history-controls">
|
||||||
|
<label class="history-days-label" for="history-days">Last</label>
|
||||||
|
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
|
||||||
|
<span class="history-days-label">days</span>
|
||||||
|
<button id="history-refresh-btn" class="history-refresh-btn" title="Refresh history">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="history-loading" class="history-loading" style="display: none;">Loading history...</div>
|
||||||
|
<div id="history-error" class="history-error" style="display: none;"></div>
|
||||||
|
<div id="no-history" class="no-history" style="display: none;">
|
||||||
|
<p>No completed downloads found in this period.</p>
|
||||||
|
</div>
|
||||||
|
<div id="history-list" class="history-list"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="downloads-list" class="downloads-list"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
|
|||||||
@@ -31,41 +31,68 @@
|
|||||||
|
|
||||||
/* ===== Theme Variables ===== */
|
/* ===== Theme Variables ===== */
|
||||||
:root, [data-theme="light"] {
|
:root, [data-theme="light"] {
|
||||||
--bg-gradient-start: #667eea;
|
/* Page background — clean off-white matching logo backdrop */
|
||||||
--bg-gradient-end: #764ba2;
|
--bg-gradient-start: #e8eef3;
|
||||||
|
--bg-gradient-end: #d4dee8;
|
||||||
|
|
||||||
|
/* Surfaces */
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--surface-alt: #f5f5f5;
|
--surface-alt: #f0f4f7;
|
||||||
--text-primary: #333333;
|
|
||||||
--text-secondary: #666666;
|
/* Typography — charcoal from logo, all meet WCAG AA on white */
|
||||||
--text-muted: #999999;
|
--text-primary: #2b2f33; /* ~14:1 on white */
|
||||||
--border: #e0e0e0;
|
--text-secondary: #4d5760; /* ~7.5:1 on white */
|
||||||
--accent: #667eea;
|
--text-muted: #6b7784; /* ~4.6:1 on white — AA compliant */
|
||||||
--accent-hover: #5568d3;
|
|
||||||
--accent-light: #e8eaf6;
|
/* Borders */
|
||||||
--series-color: #667eea;
|
--border: #c8d3db;
|
||||||
--series-bg: #e8eaf6;
|
|
||||||
--movie-color: #e040a0;
|
/* Accent — primary teal from couch outline */
|
||||||
--movie-bg: #fce4ec;
|
--accent: #1f7d94; /* ~4.6:1 on white — AA compliant */
|
||||||
--torrent-color: #26a69a;
|
--accent-hover: #165f70; /* darker for hover */
|
||||||
--torrent-bg: #e0f2f1;
|
--accent-light: #e0f0f4; /* very light teal tint for backgrounds */
|
||||||
--success: #4caf50;
|
|
||||||
|
/* Series — steel blue from sofa body */
|
||||||
|
--series-color: #1e6b8a; /* ~5.0:1 on white — AA */
|
||||||
|
--series-bg: #dceef5;
|
||||||
|
|
||||||
|
/* Movie — warm coral (complementary to teal, accessible) */
|
||||||
|
--movie-color: #b5451b; /* ~5.5:1 on white — AA */
|
||||||
|
--movie-bg: #fdeee8;
|
||||||
|
|
||||||
|
/* Torrent — mid teal-green */
|
||||||
|
--torrent-color: #1a7a6e; /* ~4.7:1 on white — AA */
|
||||||
|
--torrent-bg: #ddf2ef;
|
||||||
|
|
||||||
|
/* State colours */
|
||||||
|
--success: #2e7d32; /* ~7.1:1 on white — AA */
|
||||||
--success-bg: #e8f5e9;
|
--success-bg: #e8f5e9;
|
||||||
--info: #2196f3;
|
--info: #1565c0; /* ~7.3:1 on white — AA */
|
||||||
--info-bg: #e3f2fd;
|
--info-bg: #e3f0fb;
|
||||||
--danger: #f44336;
|
--danger: #c62828; /* ~6.5:1 on white — AA */
|
||||||
--danger-bg: #ffebee;
|
--danger-bg: #fdecea;
|
||||||
--danger-border: #ffcdd2;
|
--danger-border: #f5c6c2;
|
||||||
--progress-bg: #ffebee;
|
|
||||||
--progress-border: #ffcdd2;
|
/* Progress bar */
|
||||||
--progress-fill-start: #4caf50;
|
--progress-bg: #eaf2f5;
|
||||||
--progress-fill-end: #66bb6a;
|
--progress-border: #c8d3db;
|
||||||
--shadow: rgba(0, 0, 0, 0.1);
|
--progress-fill-start: #1f7d94;
|
||||||
--shadow-strong: rgba(0, 0, 0, 0.15);
|
--progress-fill-end: #2da0bc;
|
||||||
--footer-text: rgba(255, 255, 255, 0.9);
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow: rgba(30, 60, 80, 0.10);
|
||||||
|
--shadow-strong: rgba(30, 60, 80, 0.18);
|
||||||
|
|
||||||
|
/* Footer — dark text on light page background */
|
||||||
|
--footer-text: #4d5760;
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
--input-bg: #ffffff;
|
--input-bg: #ffffff;
|
||||||
--select-bg: #ffffff;
|
--select-bg: #ffffff;
|
||||||
|
|
||||||
|
/* Unmatched tag — amber, accessible on its bg */
|
||||||
--unmatched-tag-bg: #fff3e0;
|
--unmatched-tag-bg: #fff3e0;
|
||||||
--unmatched-tag-color: #e65100;
|
--unmatched-tag-color: #7a4000; /* ~7.1:1 on #fff3e0 — AA */
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
@@ -458,21 +485,60 @@ body {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.episode-info {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin: -2px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-info.multi-episode {
|
||||||
|
cursor: help;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-info.multi-episode:hover::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 100%;
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: pre-line;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
max-width: 320px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Detail Row (Inline) ===== */
|
/* ===== Detail Row (Inline) ===== */
|
||||||
.download-details {
|
.download-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px 14px;
|
gap: 4px 6px;
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-item {
|
.detail-item {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 4px;
|
gap: 3px;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
|
background: var(--bg-secondary, rgba(0,0,0,0.04));
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item.availability-warning .detail-value {
|
||||||
|
color: var(--danger, #e53e3e);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-label {
|
.detail-label {
|
||||||
@@ -491,10 +557,17 @@ body {
|
|||||||
/* ===== Progress Bar (Compact) ===== */
|
/* ===== Progress Bar (Compact) ===== */
|
||||||
.progress-item {
|
.progress-item {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
white-space: normal;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-container {
|
.progress-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
@@ -510,13 +583,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-segment {
|
.progress-segment {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-segment.downloaded {
|
.progress-segment.downloaded {
|
||||||
background: linear-gradient(90deg, var(--progress-fill-start) 0%, var(--progress-fill-end) 100%);
|
background: linear-gradient(90deg, var(--progress-fill-start) 0%, var(--progress-fill-end) 100%);
|
||||||
float: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-text {
|
.progress-text {
|
||||||
@@ -534,6 +609,251 @@ body {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Main Tabs ===== */
|
||||||
|
.main-tabs {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 16px auto 0;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, border-color 0.2s;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Recently Completed History ===== */
|
||||||
|
.history-container {
|
||||||
|
max-width: unset;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-days-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-days-input {
|
||||||
|
width: 52px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-refresh-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 2px 7px;
|
||||||
|
line-height: 1.4;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-refresh-btn:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-loading,
|
||||||
|
.history-error,
|
||||||
|
.no-history {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-error {
|
||||||
|
color: var(--error, #e74c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card.failed {
|
||||||
|
border-left: 3px solid var(--error, #e74c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card.imported {
|
||||||
|
border-left: 3px solid var(--success, #27ae60);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-cover {
|
||||||
|
flex: 0 0 48px;
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-cover img {
|
||||||
|
width: 48px;
|
||||||
|
height: 68px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-info {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-type-badge,
|
||||||
|
.history-outcome-badge,
|
||||||
|
.history-instance-badge {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-type-badge.series {
|
||||||
|
background: var(--badge-series-bg, #2980b9);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-type-badge.movie {
|
||||||
|
background: var(--badge-movie-bg, #8e44ad);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-outcome-badge.imported {
|
||||||
|
background: var(--success, #27ae60);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-outcome-badge.failed {
|
||||||
|
background: var(--error, #e74c3c);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-instance-badge {
|
||||||
|
background: var(--tag-bg, #ecf0f1);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 2px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-media-name {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-details {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-failure-message {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--error, #e74c3c);
|
||||||
|
background: var(--error-bg, rgba(231, 76, 60, 0.08));
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 7px;
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.history-cover {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.history-title {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Footer ===== */
|
/* ===== Footer ===== */
|
||||||
.app-footer {
|
.app-footer {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
@@ -1021,11 +1341,6 @@ body {
|
|||||||
width: 40px;
|
width: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-details {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-container {
|
.progress-container {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const sonarrRoutes = require('./routes/sonarr');
|
|||||||
const radarrRoutes = require('./routes/radarr');
|
const radarrRoutes = require('./routes/radarr');
|
||||||
const embyRoutes = require('./routes/emby');
|
const embyRoutes = require('./routes/emby');
|
||||||
const dashboardRoutes = require('./routes/dashboard');
|
const dashboardRoutes = require('./routes/dashboard');
|
||||||
|
const historyRoutes = require('./routes/history');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
baseUri: ["'self'"],
|
baseUri: ["'self'"],
|
||||||
frameAncestors: ["'none'"],
|
frameAncestors: ["'none'"],
|
||||||
formAction: ["'self'"],
|
formAction: ["'self'"],
|
||||||
upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null
|
upgradeInsecureRequests: process.env.TRUST_PROXY ? [] : null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
|
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
|
||||||
@@ -100,6 +101,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
app.use('/api/radarr', radarrRoutes);
|
app.use('/api/radarr', radarrRoutes);
|
||||||
app.use('/api/emby', embyRoutes);
|
app.use('/api/emby', embyRoutes);
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
|
app.use('/api/history', historyRoutes);
|
||||||
|
|
||||||
// Global error handler
|
// Global error handler
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ const helmet = require('helmet');
|
|||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
// Setup logging with levels
|
// Setup logging with levels
|
||||||
@@ -77,6 +79,7 @@ const sonarrRoutes = require('./routes/sonarr');
|
|||||||
const radarrRoutes = require('./routes/radarr');
|
const radarrRoutes = require('./routes/radarr');
|
||||||
const embyRoutes = require('./routes/emby');
|
const embyRoutes = require('./routes/emby');
|
||||||
const dashboardRoutes = require('./routes/dashboard');
|
const dashboardRoutes = require('./routes/dashboard');
|
||||||
|
const historyRoutes = require('./routes/history');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const verifyCsrf = require('./middleware/verifyCsrf');
|
const verifyCsrf = require('./middleware/verifyCsrf');
|
||||||
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
|
||||||
@@ -99,6 +102,9 @@ if (!process.env.EMBY_URL && process.env.NODE_ENV === 'production') {
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Resolve TLS_ENABLED early — used in Helmet CSP and server startup
|
||||||
|
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
|
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
|
||||||
// req.ip reflects the real client IP (not 127.0.0.1) and
|
// req.ip reflects the real client IP (not 127.0.0.1) and
|
||||||
@@ -137,7 +143,7 @@ app.use((req, res, next) => {
|
|||||||
baseUri: ["'self'"],
|
baseUri: ["'self'"],
|
||||||
frameAncestors: ["'none'"],
|
frameAncestors: ["'none'"],
|
||||||
formAction: ["'self'"],
|
formAction: ["'self'"],
|
||||||
upgradeInsecureRequests: process.env.NODE_ENV === 'production' ? [] : null
|
upgradeInsecureRequests: (process.env.TRUST_PROXY || TLS_ENABLED) ? [] : null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
hsts: {
|
hsts: {
|
||||||
@@ -214,15 +220,17 @@ app.use(express.static(PUBLIC_DIR, {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Serve index.html with nonce injected into the <script> and <link> tags
|
// Serve index.html with CSP nonce injected into <script> tags
|
||||||
function serveIndex(req, res) {
|
function serveIndex(req, res) {
|
||||||
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
|
||||||
if (err) return res.status(500).send('Internal Server Error');
|
if (err) return res.status(500).send('Internal Server Error');
|
||||||
const nonce = res.locals.cspNonce;
|
const nonce = res.locals.cspNonce;
|
||||||
// Inject nonce into <script> and <link rel="stylesheet"> tags
|
// Only inject nonce into <script> tags — style-src 'self' already permits
|
||||||
|
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
|
||||||
|
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
|
||||||
|
// the old nonce which no longer matches the per-request CSP header).
|
||||||
const patched = html
|
const patched = html
|
||||||
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`)
|
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
|
||||||
.replace(/<link([^>]*rel=["']stylesheet["'][^>]*)>/gi, `<link nonce="${nonce}"$1>`);
|
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
res.send(patched);
|
res.send(patched);
|
||||||
});
|
});
|
||||||
@@ -243,6 +251,7 @@ app.use('/api/sonarr', sonarrRoutes);
|
|||||||
app.use('/api/radarr', radarrRoutes);
|
app.use('/api/radarr', radarrRoutes);
|
||||||
app.use('/api/emby', embyRoutes);
|
app.use('/api/emby', embyRoutes);
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
|
app.use('/api/history', historyRoutes);
|
||||||
|
|
||||||
// SPA catch-all — serve index.html for any unmatched path
|
// SPA catch-all — serve index.html for any unmatched path
|
||||||
app.get('*', serveIndex);
|
app.get('*', serveIndex);
|
||||||
@@ -256,10 +265,52 @@ app.use((err, req, res, next) => {
|
|||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
// ---------------------------------------------------------------------------
|
||||||
|
// TLS / HTTPS support
|
||||||
|
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
|
||||||
|
// If unset, defaults to the bundled snakeoil self-signed certificate
|
||||||
|
// (localhost/127.0.0.1 only — suitable for local testing).
|
||||||
|
// Set TLS_ENABLED=false to force plain HTTP even if cert files exist.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const CERTS_DIR = path.join(__dirname, '../certs');
|
||||||
|
const TLS_CERT_PATH = process.env.TLS_CERT || path.join(CERTS_DIR, 'snakeoil.crt');
|
||||||
|
const TLS_KEY_PATH = process.env.TLS_KEY || path.join(CERTS_DIR, 'snakeoil.key');
|
||||||
|
|
||||||
|
function loadTlsCredentials() {
|
||||||
|
if (!TLS_ENABLED) return null;
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
cert: fs.readFileSync(TLS_CERT_PATH),
|
||||||
|
key: fs.readFileSync(TLS_KEY_PATH)
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[TLS] Could not load certificate files — falling back to HTTP. (${err.message})`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tlsCredentials = loadTlsCredentials();
|
||||||
|
const server = tlsCredentials
|
||||||
|
? https.createServer(tlsCredentials, app)
|
||||||
|
: http.createServer(app);
|
||||||
|
|
||||||
|
const protocol = tlsCredentials ? 'https' : 'http';
|
||||||
|
const isSnakeoil = TLS_ENABLED &&
|
||||||
|
(!process.env.TLS_CERT || process.env.TLS_CERT === TLS_CERT_PATH);
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
console.log(`=================================`);
|
console.log(`=================================`);
|
||||||
console.log(` sofarr - Your Downloads Dashboard`);
|
console.log(` sofarr - Your Downloads Dashboard`);
|
||||||
console.log(` Server running on port ${PORT}`);
|
console.log(` Server running on ${protocol}://localhost:${PORT}`);
|
||||||
|
if (tlsCredentials && isSnakeoil) {
|
||||||
|
console.warn(` [TLS] Using bundled snakeoil certificate (self-signed).`);
|
||||||
|
console.warn(` [TLS] Set TLS_CERT and TLS_KEY for a trusted certificate.`);
|
||||||
|
console.warn(` [TLS] Set TLS_ENABLED=false to disable TLS entirely.`);
|
||||||
|
} else if (tlsCredentials) {
|
||||||
|
console.log(` [TLS] Certificate: ${TLS_CERT_PATH}`);
|
||||||
|
} else {
|
||||||
|
console.warn(` [TLS] Running in plain HTTP mode — not suitable for production.`);
|
||||||
|
}
|
||||||
console.log(` Log level: ${process.env.LOG_LEVEL || 'info'}`);
|
console.log(` Log level: ${process.env.LOG_LEVEL || 'info'}`);
|
||||||
console.log(` Polling: ${POLLING_ENABLED ? POLL_INTERVAL + 'ms' : 'disabled (on-demand)'}`);
|
console.log(` Polling: ${POLLING_ENABLED ? POLL_INTERVAL + 'ms' : 'disabled (on-demand)'}`);
|
||||||
console.log(` Trust proxy: ${process.env.TRUST_PROXY || 'disabled'}`);
|
console.log(` Trust proxy: ${process.env.TRUST_PROXY || 'disabled'}`);
|
||||||
|
|||||||
@@ -70,13 +70,15 @@ router.post('/login', loginLimiter, async (req, res) => {
|
|||||||
// Set authentication cookie (signed when COOKIE_SECRET is set).
|
// Set authentication cookie (signed when COOKIE_SECRET is set).
|
||||||
// rememberMe=true → persistent cookie, expires in 30 days
|
// rememberMe=true → persistent cookie, expires in 30 days
|
||||||
// rememberMe=false → session cookie, expires when browser closes
|
// rememberMe=false → session cookie, expires when browser closes
|
||||||
// secure is always true — the app should sit behind HTTPS in production;
|
// secure:true only when TRUST_PROXY is set — i.e. a TLS-terminating reverse
|
||||||
// behind a reverse proxy set TRUST_PROXY=1 so req.secure works correctly.
|
// proxy is in front. Without it the app may be accessed over plain HTTP and
|
||||||
|
// secure cookies would never be sent back by the browser.
|
||||||
const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin });
|
const cookiePayload = JSON.stringify({ id: user.Id, name: user.Name, isAdmin });
|
||||||
const signed = !!process.env.COOKIE_SECRET;
|
const signed = !!process.env.COOKIE_SECRET;
|
||||||
|
const secureCookie = !!process.env.TRUST_PROXY; // only send over HTTPS when behind a TLS proxy
|
||||||
const cookieOptions = {
|
const cookieOptions = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: secureCookie,
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
signed,
|
signed,
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -91,7 +93,7 @@ router.post('/login', loginLimiter, async (req, res) => {
|
|||||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||||
res.cookie('csrf_token', csrfToken, {
|
res.cookie('csrf_token', csrfToken, {
|
||||||
httpOnly: false, // intentionally readable by JS for the double-submit pattern
|
httpOnly: false, // intentionally readable by JS for the double-submit pattern
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: secureCookie,
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
path: '/'
|
path: '/'
|
||||||
});
|
});
|
||||||
@@ -142,7 +144,7 @@ router.get('/csrf', (req, res) => {
|
|||||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||||
res.cookie('csrf_token', csrfToken, {
|
res.cookie('csrf_token', csrfToken, {
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: !!process.env.TRUST_PROXY,
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
path: '/'
|
path: '/'
|
||||||
});
|
});
|
||||||
@@ -168,14 +170,14 @@ router.post('/logout', async (req, res) => {
|
|||||||
}
|
}
|
||||||
res.clearCookie('emby_user', {
|
res.clearCookie('emby_user', {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: !!process.env.TRUST_PROXY,
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
signed: !!process.env.COOKIE_SECRET,
|
signed: !!process.env.COOKIE_SECRET,
|
||||||
path: '/'
|
path: '/'
|
||||||
});
|
});
|
||||||
res.clearCookie('csrf_token', {
|
res.clearCookie('csrf_token', {
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: !!process.env.TRUST_PROXY,
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
path: '/'
|
path: '/'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -94,6 +94,39 @@ function getRadarrLink(movie) {
|
|||||||
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract episode info from a Sonarr queue/history record.
|
||||||
|
// Returns { season, episode, title } or null if data is missing.
|
||||||
|
function extractEpisode(record) {
|
||||||
|
const ep = record.episode || {};
|
||||||
|
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
|
||||||
|
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
|
||||||
|
if (s == null || e == null) return null;
|
||||||
|
const title = ep.title || null;
|
||||||
|
return { season: s, episode: e, title };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all episodes associated with a download by matching all queue/history records
|
||||||
|
// that share the same title string. Returns sorted array of { season, episode, title }.
|
||||||
|
function gatherEpisodes(titleLower, sonarrRecords) {
|
||||||
|
const episodes = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const r of sonarrRecords) {
|
||||||
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||||
|
if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) {
|
||||||
|
const ep = extractEpisode(r);
|
||||||
|
if (ep) {
|
||||||
|
const key = `${ep.season}x${ep.episode}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
episodes.push(ep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
|
||||||
|
return episodes;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch all Emby users and return a Map<lowerName -> displayName> (and sanitized variants).
|
// 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.
|
// Result is cached for 60s to avoid hammering Emby on every dashboard poll.
|
||||||
async function getEmbyUsers() {
|
async function getEmbyUsers() {
|
||||||
@@ -277,7 +310,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
speed: slotState.speed,
|
speed: slotState.speed,
|
||||||
eta: slot.timeleft,
|
eta: slot.timeleft,
|
||||||
seriesName: series.title,
|
seriesName: series.title,
|
||||||
episodeInfo: sonarrMatch,
|
episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records),
|
||||||
allTags,
|
allTags,
|
||||||
matchedUserTag: matchedUserTag || null,
|
matchedUserTag: matchedUserTag || null,
|
||||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||||
@@ -374,7 +407,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
size: slot.size,
|
size: slot.size,
|
||||||
completedAt: slot.completed_time,
|
completedAt: slot.completed_time,
|
||||||
seriesName: series.title,
|
seriesName: series.title,
|
||||||
episodeInfo: sonarrMatch,
|
episodes: gatherEpisodes(nzbNameLower, sonarrHistory.data.records),
|
||||||
allTags,
|
allTags,
|
||||||
matchedUserTag: matchedUserTag || null,
|
matchedUserTag: matchedUserTag || null,
|
||||||
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||||
@@ -477,7 +510,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
download.type = 'series';
|
download.type = 'series';
|
||||||
download.coverArt = getCoverArt(series);
|
download.coverArt = getCoverArt(series);
|
||||||
download.seriesName = series.title;
|
download.seriesName = series.title;
|
||||||
download.episodeInfo = sonarrMatch;
|
download.episodes = gatherEpisodes(torrentNameLower, sonarrQueue.data.records);
|
||||||
download.allTags = allTags;
|
download.allTags = allTags;
|
||||||
download.matchedUserTag = matchedUserTag || null;
|
download.matchedUserTag = matchedUserTag || null;
|
||||||
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
||||||
@@ -547,7 +580,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
download.type = 'series';
|
download.type = 'series';
|
||||||
download.coverArt = getCoverArt(series);
|
download.coverArt = getCoverArt(series);
|
||||||
download.seriesName = series.title;
|
download.seriesName = series.title;
|
||||||
download.episodeInfo = sonarrHistoryMatch;
|
download.episodes = gatherEpisodes(torrentNameLower, sonarrHistory.data.records);
|
||||||
download.allTags = allTags;
|
download.allTags = allTags;
|
||||||
download.matchedUserTag = matchedUserTag || null;
|
download.matchedUserTag = matchedUserTag || null;
|
||||||
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
||||||
@@ -857,7 +890,7 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||||
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, seriesName: series.title, episodeInfo: sonarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
||||||
const issues = getImportIssues(sonarrMatch);
|
const issues = getImportIssues(sonarrMatch);
|
||||||
if (issues) dlObj.importIssues = issues;
|
if (issues) dlObj.importIssues = issues;
|
||||||
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); }
|
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); }
|
||||||
@@ -904,7 +937,7 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||||
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slot.status, size: slot.size, completedAt: slot.completed_time, seriesName: series.title, episodeInfo: sonarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slot.status, size: slot.size, completedAt: slot.completed_time, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrHistory.data.records), 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); }
|
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); }
|
||||||
userDownloads.push(dlObj);
|
userDownloads.push(dlObj);
|
||||||
}
|
}
|
||||||
@@ -944,7 +977,7 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||||
const download = mapTorrentToDownload(torrent);
|
const download = mapTorrentToDownload(torrent);
|
||||||
Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodeInfo: sonarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodes: gatherEpisodes(torrentNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
||||||
const issues = getImportIssues(sonarrMatch); if (issues) download.importIssues = issues;
|
const issues = getImportIssues(sonarrMatch); if (issues) download.importIssues = issues;
|
||||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); }
|
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); }
|
||||||
userDownloads.push(download); continue;
|
userDownloads.push(download); continue;
|
||||||
@@ -976,7 +1009,7 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||||
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
||||||
const download = mapTorrentToDownload(torrent);
|
const download = mapTorrentToDownload(torrent);
|
||||||
Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodeInfo: sonarrHistoryMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodes: gatherEpisodes(torrentNameLower, sonarrHistory.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
||||||
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); }
|
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); }
|
||||||
userDownloads.push(download); continue;
|
userDownloads.push(download); continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const axios = require('axios');
|
||||||
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
const cache = require('../utils/cache');
|
||||||
|
const { fetchSonarrHistory, fetchRadarrHistory, classifySonarrEvent, classifyRadarrEvent } = require('../utils/historyFetcher');
|
||||||
|
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
||||||
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
|
|
||||||
|
// Re-use the same tag/cover-art helpers as dashboard.js by importing them
|
||||||
|
// from a shared location. For now they are inlined here to keep dashboard.js
|
||||||
|
// untouched (zero-conflict v1 merges). If these diverge they can be extracted
|
||||||
|
// into server/utils/dashboardHelpers.js in a later refactor.
|
||||||
|
|
||||||
|
function getCoverArt(item) {
|
||||||
|
if (!item || !item.images) return null;
|
||||||
|
const poster = item.images.find(img => img.coverType === 'poster');
|
||||||
|
if (poster) return poster.remoteUrl || poster.url || null;
|
||||||
|
const fanart = item.images.find(img => img.coverType === 'fanart');
|
||||||
|
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTagLabel(input) {
|
||||||
|
if (!input) return '';
|
||||||
|
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagMatchesUser(tag, username) {
|
||||||
|
if (!tag || !username) return false;
|
||||||
|
const tagLower = tag.toLowerCase();
|
||||||
|
if (tagLower === username) return true;
|
||||||
|
if (tagLower === sanitizeTagLabel(username)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAllTags(tags, tagMap) {
|
||||||
|
if (!tags || tags.length === 0) return [];
|
||||||
|
if (tagMap) return tags.map(id => tagMap.get(id)).filter(Boolean);
|
||||||
|
return tags.map(t => t && t.label).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEmbyUsers() {
|
||||||
|
const cached = cache.get('emby:users');
|
||||||
|
if (cached) return cached;
|
||||||
|
try {
|
||||||
|
const embyUrl = process.env.EMBY_URL;
|
||||||
|
const embyKey = process.env.EMBY_API_KEY;
|
||||||
|
if (!embyUrl || !embyKey) return new Map();
|
||||||
|
const res = await axios.get(`${embyUrl}/Users`, { params: { api_key: embyKey } });
|
||||||
|
const users = res.data || [];
|
||||||
|
const map = new Map();
|
||||||
|
for (const u of users) {
|
||||||
|
if (!u.Name) continue;
|
||||||
|
const lower = u.Name.toLowerCase();
|
||||||
|
map.set(lower, u.Name);
|
||||||
|
map.set(sanitizeTagLabel(lower), u.Name);
|
||||||
|
}
|
||||||
|
cache.set('emby:users', map, 60000);
|
||||||
|
return map;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[History] Failed to fetch Emby users:', err.message);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTagBadges(allTags, embyUserMap) {
|
||||||
|
return allTags.map(label => {
|
||||||
|
const lower = label.toLowerCase();
|
||||||
|
const sanitized = sanitizeTagLabel(label);
|
||||||
|
const matchedUser = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
|
||||||
|
return { label, matchedUser };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract episode info from a Sonarr history record.
|
||||||
|
function extractEpisode(record) {
|
||||||
|
const ep = record.episode || {};
|
||||||
|
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
|
||||||
|
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
|
||||||
|
if (s == null || e == null) return null;
|
||||||
|
const title = ep.title || null;
|
||||||
|
return { season: s, episode: e, title };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all episodes associated with a download by matching all history records
|
||||||
|
// that share the same source title. Returns sorted, deduplicated array.
|
||||||
|
function gatherEpisodes(titleLower, records) {
|
||||||
|
const episodes = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const r of records) {
|
||||||
|
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
||||||
|
if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) {
|
||||||
|
const ep = extractEpisode(r);
|
||||||
|
if (ep) {
|
||||||
|
const key = `${ep.season}x${ep.episode}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
episodes.push(ep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
|
||||||
|
return episodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSonarrLink(series) {
|
||||||
|
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
||||||
|
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRadarrLink(movie) {
|
||||||
|
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
|
||||||
|
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/history/recent
|
||||||
|
*
|
||||||
|
* Returns Sonarr/Radarr history records (imported + failed) for the
|
||||||
|
* authenticated user, filtered to the last RECENT_COMPLETED_DAYS days
|
||||||
|
* (default 7, overridable via env or ?days= query param).
|
||||||
|
*
|
||||||
|
* Response shape:
|
||||||
|
* {
|
||||||
|
* user: string,
|
||||||
|
* isAdmin: boolean,
|
||||||
|
* days: number,
|
||||||
|
* history: HistoryItem[]
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* HistoryItem shape:
|
||||||
|
* {
|
||||||
|
* type: 'series'|'movie',
|
||||||
|
* outcome: 'imported'|'failed',
|
||||||
|
* title: string, // sourceTitle from arr record
|
||||||
|
* seriesName?: string, // series.title (Sonarr)
|
||||||
|
* movieName?: string, // movie.title (Radarr)
|
||||||
|
* coverArt: string|null,
|
||||||
|
* completedAt: string, // ISO date string from arr record
|
||||||
|
* quality: string|null,
|
||||||
|
* instanceName: string, // arr instance name
|
||||||
|
* arrLink: string|null, // link to item in Sonarr/Radarr UI
|
||||||
|
* allTags: string[],
|
||||||
|
* matchedUserTag: string|null,
|
||||||
|
* // admin-only:
|
||||||
|
* arrRecordId?: number,
|
||||||
|
* failureMessage?: string,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
router.get('/recent', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user;
|
||||||
|
const username = user.name.toLowerCase();
|
||||||
|
const isAdmin = !!user.isAdmin;
|
||||||
|
const showAll = isAdmin && req.query.showAll === 'true';
|
||||||
|
|
||||||
|
const defaultDays = parseInt(process.env.RECENT_COMPLETED_DAYS, 10) || 7;
|
||||||
|
const requestedDays = parseInt(req.query.days, 10);
|
||||||
|
const days = (requestedDays > 0 && requestedDays <= 90) ? requestedDays : defaultDays;
|
||||||
|
|
||||||
|
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// Fetch tag maps and history in parallel
|
||||||
|
const sonarrInstances = getSonarrInstances();
|
||||||
|
const radarrInstances = getRadarrInstances();
|
||||||
|
|
||||||
|
const [sonarrHistory, radarrHistory, embyUserMap] = await Promise.all([
|
||||||
|
fetchSonarrHistory(since),
|
||||||
|
fetchRadarrHistory(since),
|
||||||
|
showAll ? getEmbyUsers() : Promise.resolve(new Map())
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build tag maps from the cached poll data where available,
|
||||||
|
// falling back to what's embedded in history records
|
||||||
|
const sonarrTagsData = cache.get('poll:sonarr-tags') || [];
|
||||||
|
const radarrTagsData = cache.get('poll:radarr-tags') || [];
|
||||||
|
const sonarrTagMap = new Map(sonarrTagsData.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
||||||
|
const radarrTagMap = new Map(radarrTagsData.map(t => [t.id, t.label]));
|
||||||
|
|
||||||
|
const historyItems = [];
|
||||||
|
|
||||||
|
// --- Sonarr history ---
|
||||||
|
for (const record of sonarrHistory) {
|
||||||
|
try {
|
||||||
|
const outcome = classifySonarrEvent(record.eventType);
|
||||||
|
if (outcome === 'other') continue;
|
||||||
|
|
||||||
|
const series = record.series;
|
||||||
|
if (!series) continue;
|
||||||
|
|
||||||
|
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
||||||
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
||||||
|
const hasAnyTag = allTags.length > 0;
|
||||||
|
|
||||||
|
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
|
||||||
|
|
||||||
|
const quality = record.quality && record.quality.quality && record.quality.quality.name
|
||||||
|
? record.quality.quality.name
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const sourceTitle = record.sourceTitle || record.title || series.title;
|
||||||
|
const item = {
|
||||||
|
type: 'series',
|
||||||
|
outcome,
|
||||||
|
title: sourceTitle,
|
||||||
|
seriesName: series.title,
|
||||||
|
episodes: gatherEpisodes(sourceTitle.toLowerCase(), sonarrHistory),
|
||||||
|
coverArt: getCoverArt(series),
|
||||||
|
completedAt: record.date,
|
||||||
|
quality,
|
||||||
|
instanceName: record._instanceName || null,
|
||||||
|
arrLink: getSonarrLink(series),
|
||||||
|
allTags,
|
||||||
|
matchedUserTag: matchedUserTag || null,
|
||||||
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
item.arrRecordId = record.id;
|
||||||
|
if (outcome === 'failed' && record.data && record.data.message) {
|
||||||
|
item.failureMessage = record.data.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
historyItems.push(item);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[History] Error processing Sonarr record:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Radarr history ---
|
||||||
|
for (const record of radarrHistory) {
|
||||||
|
try {
|
||||||
|
const outcome = classifyRadarrEvent(record.eventType);
|
||||||
|
if (outcome === 'other') continue;
|
||||||
|
|
||||||
|
const movie = record.movie;
|
||||||
|
if (!movie) continue;
|
||||||
|
|
||||||
|
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
||||||
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
||||||
|
const hasAnyTag = allTags.length > 0;
|
||||||
|
|
||||||
|
if (!(showAll ? hasAnyTag : !!matchedUserTag)) continue;
|
||||||
|
|
||||||
|
const quality = record.quality && record.quality.quality && record.quality.quality.name
|
||||||
|
? record.quality.quality.name
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
type: 'movie',
|
||||||
|
outcome,
|
||||||
|
title: record.sourceTitle || record.title || movie.title,
|
||||||
|
movieName: movie.title,
|
||||||
|
coverArt: getCoverArt(movie),
|
||||||
|
completedAt: record.date,
|
||||||
|
quality,
|
||||||
|
instanceName: record._instanceName || null,
|
||||||
|
arrLink: getRadarrLink(movie),
|
||||||
|
allTags,
|
||||||
|
matchedUserTag: matchedUserTag || null,
|
||||||
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
item.arrRecordId = record.id;
|
||||||
|
if (outcome === 'failed' && record.data && record.data.message) {
|
||||||
|
item.failureMessage = record.data.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
historyItems.push(item);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[History] Error processing Radarr record:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort newest first
|
||||||
|
historyItems.sort((a, b) => new Date(b.completedAt) - new Date(a.completedAt));
|
||||||
|
|
||||||
|
console.log(`[History] Returning ${historyItems.length} items for user ${user.name} (days=${days}, showAll=${showAll})`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: user.name,
|
||||||
|
isAdmin,
|
||||||
|
days,
|
||||||
|
history: historyItems
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[History] Error:', err.message);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch history', details: sanitizeError(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const cache = require('./cache');
|
||||||
|
const { getSonarrInstances, getRadarrInstances } = require('./config');
|
||||||
|
|
||||||
|
// Cache TTL for recent-history data: 5 minutes.
|
||||||
|
// History changes slowly compared to active downloads.
|
||||||
|
const HISTORY_CACHE_TTL = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Sonarr event types that represent a successful import
|
||||||
|
const SONARR_IMPORTED_EVENTS = new Set(['downloadFolderImported', 'downloadImported']);
|
||||||
|
// Sonarr event types that represent a failed import
|
||||||
|
const SONARR_FAILED_EVENTS = new Set(['downloadFailed', 'importFailed']);
|
||||||
|
// Radarr equivalents
|
||||||
|
const RADARR_IMPORTED_EVENTS = new Set(['downloadFolderImported', 'downloadImported']);
|
||||||
|
const RADARR_FAILED_EVENTS = new Set(['downloadFailed', 'importFailed']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch recent history records from all Sonarr instances for the given date window.
|
||||||
|
* Results are cached under 'history:sonarr' for HISTORY_CACHE_TTL.
|
||||||
|
* @param {Date} since - Only include records on or after this date
|
||||||
|
* @returns {Promise<Array>} Flat array of Sonarr history records (with _instanceUrl and _instanceName)
|
||||||
|
*/
|
||||||
|
async function fetchSonarrHistory(since) {
|
||||||
|
const cacheKey = 'history:sonarr';
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const instances = getSonarrInstances();
|
||||||
|
const results = await Promise.all(instances.map(async inst => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${inst.url}/api/v3/history`, {
|
||||||
|
headers: { 'X-Api-Key': inst.apiKey },
|
||||||
|
params: {
|
||||||
|
pageSize: 100,
|
||||||
|
sortKey: 'date',
|
||||||
|
sortDir: 'descending',
|
||||||
|
includeSeries: true,
|
||||||
|
includeEpisode: true,
|
||||||
|
startDate: since.toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const records = (response.data && response.data.records) || [];
|
||||||
|
return records.map(r => {
|
||||||
|
if (r.series) r.series._instanceUrl = inst.url;
|
||||||
|
if (r.series) r.series._instanceName = inst.name || inst.id;
|
||||||
|
r._instanceUrl = inst.url;
|
||||||
|
r._instanceName = inst.name || inst.id;
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[HistoryFetcher] Sonarr ${inst.id} error:`, err.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const flat = results.flat();
|
||||||
|
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
|
||||||
|
return flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch recent history records from all Radarr instances for the given date window.
|
||||||
|
* Results are cached under 'history:radarr' for HISTORY_CACHE_TTL.
|
||||||
|
* @param {Date} since - Only include records on or after this date
|
||||||
|
* @returns {Promise<Array>} Flat array of Radarr history records (with _instanceUrl and _instanceName)
|
||||||
|
*/
|
||||||
|
async function fetchRadarrHistory(since) {
|
||||||
|
const cacheKey = 'history:radarr';
|
||||||
|
const cached = cache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const instances = getRadarrInstances();
|
||||||
|
const results = await Promise.all(instances.map(async inst => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${inst.url}/api/v3/history`, {
|
||||||
|
headers: { 'X-Api-Key': inst.apiKey },
|
||||||
|
params: {
|
||||||
|
pageSize: 100,
|
||||||
|
sortKey: 'date',
|
||||||
|
sortDir: 'descending',
|
||||||
|
includeMovie: true,
|
||||||
|
startDate: since.toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const records = (response.data && response.data.records) || [];
|
||||||
|
return records.map(r => {
|
||||||
|
if (r.movie) r.movie._instanceUrl = inst.url;
|
||||||
|
if (r.movie) r.movie._instanceName = inst.name || inst.id;
|
||||||
|
r._instanceUrl = inst.url;
|
||||||
|
r._instanceName = inst.name || inst.id;
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[HistoryFetcher] Radarr ${inst.id} error:`, err.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const flat = results.flat();
|
||||||
|
cache.set(cacheKey, flat, HISTORY_CACHE_TTL);
|
||||||
|
return flat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a Sonarr history record's event type.
|
||||||
|
* @param {string} eventType
|
||||||
|
* @returns {'imported'|'failed'|'other'}
|
||||||
|
*/
|
||||||
|
function classifySonarrEvent(eventType) {
|
||||||
|
if (SONARR_IMPORTED_EVENTS.has(eventType)) return 'imported';
|
||||||
|
if (SONARR_FAILED_EVENTS.has(eventType)) return 'failed';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a Radarr history record's event type.
|
||||||
|
* @param {string} eventType
|
||||||
|
* @returns {'imported'|'failed'|'other'}
|
||||||
|
*/
|
||||||
|
function classifyRadarrEvent(eventType) {
|
||||||
|
if (RADARR_IMPORTED_EVENTS.has(eventType)) return 'imported';
|
||||||
|
if (RADARR_FAILED_EVENTS.has(eventType)) return 'failed';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate cached history so the next request fetches fresh data.
|
||||||
|
* Called externally if needed (e.g. after a forced refresh).
|
||||||
|
*/
|
||||||
|
function invalidateHistoryCache() {
|
||||||
|
cache.invalidate('history:sonarr');
|
||||||
|
cache.invalidate('history:radarr');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fetchSonarrHistory,
|
||||||
|
fetchRadarrHistory,
|
||||||
|
classifySonarrEvent,
|
||||||
|
classifyRadarrEvent,
|
||||||
|
invalidateHistoryCache,
|
||||||
|
HISTORY_CACHE_TTL
|
||||||
|
};
|
||||||
@@ -71,7 +71,7 @@ async function pollAllServices() {
|
|||||||
timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst =>
|
timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst =>
|
||||||
axios.get(`${inst.url}/api/v3/queue`, {
|
axios.get(`${inst.url}/api/v3/queue`, {
|
||||||
headers: { 'X-Api-Key': inst.apiKey },
|
headers: { 'X-Api-Key': inst.apiKey },
|
||||||
params: { includeSeries: true }
|
params: { includeSeries: true, includeEpisode: true }
|
||||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||||
console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message);
|
console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message);
|
||||||
return { instance: inst.id, data: { records: [] } };
|
return { instance: inst.id, data: { records: [] } };
|
||||||
@@ -80,7 +80,7 @@ async function pollAllServices() {
|
|||||||
timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst =>
|
timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst =>
|
||||||
axios.get(`${inst.url}/api/v3/history`, {
|
axios.get(`${inst.url}/api/v3/history`, {
|
||||||
headers: { 'X-Api-Key': inst.apiKey },
|
headers: { 'X-Api-Key': inst.apiKey },
|
||||||
params: { pageSize: 10 }
|
params: { pageSize: 10, includeEpisode: true }
|
||||||
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
|
||||||
console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message);
|
console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message);
|
||||||
return { instance: inst.id, data: { records: [] } };
|
return { instance: inst.id, data: { records: [] } };
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for GET /api/history/recent
|
||||||
|
*
|
||||||
|
* Uses supertest against createApp() with vi.mock to stub historyFetcher
|
||||||
|
* (avoids nock/ESM-CJS interop issues with axios) and nock for Emby auth.
|
||||||
|
* Covers:
|
||||||
|
* - 401 when unauthenticated
|
||||||
|
* - Empty history response when arr returns no records
|
||||||
|
* - Filters out records whose eventType is not imported/failed
|
||||||
|
* - Returns imported and failed records for tagged series/movies
|
||||||
|
* - ?days= param is respected (default 7, capped at 90)
|
||||||
|
* - failureMessage included for admins on failed records
|
||||||
|
*/
|
||||||
|
|
||||||
|
import request from 'supertest';
|
||||||
|
import nock from 'nock';
|
||||||
|
import { beforeEach, afterEach } from 'vitest';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
import { createApp } from '../../server/app.js';
|
||||||
|
|
||||||
|
// Use createRequire to get the same CJS singleton cache instance that
|
||||||
|
// server/utils/historyFetcher.js and server/routes/history.js use via
|
||||||
|
// require('./cache'). A plain ESM `import cache from '...'` resolves
|
||||||
|
// to a different module identity under vitest's ESM runtime.
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const cache = require('../../server/utils/cache.js');
|
||||||
|
|
||||||
|
const EMBY_BASE = 'https://emby.test';
|
||||||
|
|
||||||
|
process.env.EMBY_URL = EMBY_BASE;
|
||||||
|
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sk' }
|
||||||
|
]);
|
||||||
|
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// --- Fixtures ---
|
||||||
|
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
|
||||||
|
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
|
||||||
|
const EMBY_ADMIN = { Id: 'uid2', Name: 'admin', Policy: { IsAdministrator: true } };
|
||||||
|
const EMBY_AUTH_ADMIN = { AccessToken: 'tok2', User: { Id: 'uid2', Name: 'admin' } };
|
||||||
|
|
||||||
|
// Sonarr tag: id 1 → 'alice'
|
||||||
|
const SONARR_TAGS = [{ id: 1, label: 'alice' }];
|
||||||
|
|
||||||
|
const SONARR_RECORD_IMPORTED = {
|
||||||
|
id: 100,
|
||||||
|
eventType: 'downloadFolderImported',
|
||||||
|
sourceTitle: 'Show.S01E01.720p',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
quality: { quality: { name: '720p' } },
|
||||||
|
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||||
|
seriesId: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
const SONARR_RECORD_FAILED = {
|
||||||
|
id: 101,
|
||||||
|
eventType: 'downloadFailed',
|
||||||
|
sourceTitle: 'Show.S01E02.720p',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
quality: { quality: { name: '720p' } },
|
||||||
|
data: { message: 'Not enough disk space' },
|
||||||
|
series: { id: 11, title: 'Admin Show', titleSlug: 'admin-show', tags: [2], images: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tag id 2 → 'admin' (used in the failed-import admin test)
|
||||||
|
const SONARR_TAGS_WITH_ADMIN = [{ id: 1, label: 'alice' }, { id: 2, label: 'admin' }];
|
||||||
|
|
||||||
|
const SONARR_RECORD_FAILED_ALICE = { // failed record tagged alice, for event-type filtering test
|
||||||
|
id: 103,
|
||||||
|
eventType: 'downloadFailed',
|
||||||
|
sourceTitle: 'Show.S01E02.720p',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
quality: { quality: { name: '720p' } },
|
||||||
|
data: { message: 'Disk full' },
|
||||||
|
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||||
|
seriesId: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
const SONARR_RECORD_GRABBED = {
|
||||||
|
id: 102,
|
||||||
|
eventType: 'grabbed',
|
||||||
|
sourceTitle: 'Show.S01E03.720p',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
||||||
|
seriesId: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
const RADARR_RECORD_IMPORTED = {
|
||||||
|
id: 200,
|
||||||
|
eventType: 'downloadFolderImported',
|
||||||
|
sourceTitle: 'My.Movie.2024.1080p',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
quality: { quality: { name: '1080p' } },
|
||||||
|
movie: { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [1], images: [] },
|
||||||
|
movieId: 20
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
function interceptLogin(userBody = EMBY_USER, authBody = EMBY_AUTH) {
|
||||||
|
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, authBody);
|
||||||
|
nock(EMBY_BASE).get(/\/Users\//).reply(200, userBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-seed the history cache keys that fetchSonarrHistory/fetchRadarrHistory check
|
||||||
|
// first, so they return without making any HTTP calls.
|
||||||
|
const CACHE_TTL = 60 * 60 * 1000; // 1 hour — won't expire during a test run
|
||||||
|
|
||||||
|
function setHistory(sonarrRecords = [], radarrRecords = []) {
|
||||||
|
cache.set('history:sonarr', sonarrRecords.map(r => ({
|
||||||
|
...r,
|
||||||
|
_instanceName: 'Main Sonarr',
|
||||||
|
series: r.series ? { ...r.series, _instanceUrl: 'https://sonarr.test', _instanceName: 'Main Sonarr' } : undefined
|
||||||
|
})), CACHE_TTL);
|
||||||
|
cache.set('history:radarr', radarrRecords.map(r => ({
|
||||||
|
...r,
|
||||||
|
_instanceName: 'Main Radarr',
|
||||||
|
movie: r.movie ? { ...r.movie, _instanceUrl: 'https://radarr.test', _instanceName: 'Main Radarr' } : undefined
|
||||||
|
})), CACHE_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAs(app, userBody = EMBY_USER, authBody = EMBY_AUTH) {
|
||||||
|
interceptLogin(userBody, authBody);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: userBody.Name, password: 'pw' });
|
||||||
|
const cookies = res.headers['set-cookie'];
|
||||||
|
const csrf = res.body.csrfToken;
|
||||||
|
return { cookies, csrf };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear history caches so each test controls its own data
|
||||||
|
cache.invalidate('history:sonarr');
|
||||||
|
cache.invalidate('history:radarr');
|
||||||
|
// Default: empty history
|
||||||
|
setHistory([], []);
|
||||||
|
// Seed poll tag caches so the route can resolve tags
|
||||||
|
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], 60000);
|
||||||
|
cache.set('poll:radarr-tags', [], 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
cache.invalidate('history:sonarr');
|
||||||
|
cache.invalidate('history:radarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/history/recent', () => {
|
||||||
|
describe('authentication', () => {
|
||||||
|
it('returns 401 when not logged in', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/history/recent');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('empty history', () => {
|
||||||
|
it('returns empty array when arr returns no records', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.history).toEqual([]);
|
||||||
|
expect(res.body.days).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('event type filtering', () => {
|
||||||
|
it('includes imported and failed records, excludes grabbed', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
setHistory(
|
||||||
|
[SONARR_RECORD_IMPORTED, SONARR_RECORD_FAILED_ALICE, SONARR_RECORD_GRABBED],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const outcomes = res.body.history.map(h => h.outcome);
|
||||||
|
expect(outcomes).toContain('imported');
|
||||||
|
expect(outcomes).toContain('failed');
|
||||||
|
expect(res.body.history).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tag filtering', () => {
|
||||||
|
it('only returns records tagged for the current user', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
setHistory([SONARR_RECORD_IMPORTED], []);
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.history).toHaveLength(1);
|
||||||
|
expect(res.body.history[0].seriesName).toBe('My Show');
|
||||||
|
expect(res.body.history[0].outcome).toBe('imported');
|
||||||
|
expect(res.body.history[0].quality).toBe('720p');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes records tagged for a different user', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const bobAuth = { AccessToken: 'tok3', User: { Id: 'uid3', Name: 'bob' } };
|
||||||
|
const bobUser = { Id: 'uid3', Name: 'bob', Policy: { IsAdministrator: false } };
|
||||||
|
setHistory([SONARR_RECORD_IMPORTED], []);
|
||||||
|
const { cookies } = await loginAs(app, bobUser, bobAuth);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.history).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('radarr records', () => {
|
||||||
|
it('returns movie history items', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
cache.set('poll:radarr-tags', [{ id: 1, label: 'alice' }], 60000);
|
||||||
|
setHistory([], [RADARR_RECORD_IMPORTED]);
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.history).toHaveLength(1);
|
||||||
|
expect(res.body.history[0].type).toBe('movie');
|
||||||
|
expect(res.body.history[0].movieName).toBe('My Movie');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('?days parameter', () => {
|
||||||
|
it('uses custom days value', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent?days=14')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.days).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps days at 90', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent?days=999')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.days).toBe(7); // falls back to default when > 90
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('failed import details', () => {
|
||||||
|
it('includes failureMessage for admin on failed records', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS_WITH_ADMIN }], 60000);
|
||||||
|
setHistory([SONARR_RECORD_FAILED], []);
|
||||||
|
const { cookies } = await loginAs(app, EMBY_ADMIN, EMBY_AUTH_ADMIN);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const failed = res.body.history.find(h => h.outcome === 'failed');
|
||||||
|
expect(failed).toBeDefined();
|
||||||
|
expect(failed.failureMessage).toBe('Not enough disk space');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('response shape', () => {
|
||||||
|
it('returns correct top-level fields', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/history/recent')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('user');
|
||||||
|
expect(res.body).toHaveProperty('isAdmin');
|
||||||
|
expect(res.body).toHaveProperty('days');
|
||||||
|
expect(res.body).toHaveProperty('history');
|
||||||
|
expect(Array.isArray(res.body.history)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for server/utils/historyFetcher.js
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - classifySonarrEvent / classifyRadarrEvent event classification
|
||||||
|
* - fetchSonarrHistory / fetchRadarrHistory: successful fetch, cache hit, per-instance errors
|
||||||
|
* - invalidateHistoryCache
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import nock from 'nock';
|
||||||
|
|
||||||
|
// Env must be set before importing modules that read it at load time
|
||||||
|
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sonarr-key' }
|
||||||
|
]);
|
||||||
|
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'radarr-key' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { classifySonarrEvent, classifyRadarrEvent, fetchSonarrHistory, fetchRadarrHistory, invalidateHistoryCache } =
|
||||||
|
await import('../../server/utils/historyFetcher.js');
|
||||||
|
|
||||||
|
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
invalidateHistoryCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classifySonarrEvent', () => {
|
||||||
|
it('returns imported for downloadFolderImported', () => {
|
||||||
|
expect(classifySonarrEvent('downloadFolderImported')).toBe('imported');
|
||||||
|
});
|
||||||
|
it('returns imported for downloadImported', () => {
|
||||||
|
expect(classifySonarrEvent('downloadImported')).toBe('imported');
|
||||||
|
});
|
||||||
|
it('returns failed for downloadFailed', () => {
|
||||||
|
expect(classifySonarrEvent('downloadFailed')).toBe('failed');
|
||||||
|
});
|
||||||
|
it('returns failed for importFailed', () => {
|
||||||
|
expect(classifySonarrEvent('importFailed')).toBe('failed');
|
||||||
|
});
|
||||||
|
it('returns other for grabbed', () => {
|
||||||
|
expect(classifySonarrEvent('grabbed')).toBe('other');
|
||||||
|
});
|
||||||
|
it('returns other for unknown event', () => {
|
||||||
|
expect(classifySonarrEvent('someFutureEvent')).toBe('other');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('classifyRadarrEvent', () => {
|
||||||
|
it('returns imported for downloadFolderImported', () => {
|
||||||
|
expect(classifyRadarrEvent('downloadFolderImported')).toBe('imported');
|
||||||
|
});
|
||||||
|
it('returns failed for downloadFailed', () => {
|
||||||
|
expect(classifyRadarrEvent('downloadFailed')).toBe('failed');
|
||||||
|
});
|
||||||
|
it('returns other for grabbed', () => {
|
||||||
|
expect(classifyRadarrEvent('grabbed')).toBe('other');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchSonarrHistory', () => {
|
||||||
|
const mockRecords = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
eventType: 'downloadFolderImported',
|
||||||
|
sourceTitle: 'Show.S01E01',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] },
|
||||||
|
seriesId: 10
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
it('fetches records and tags them with _instanceUrl and _instanceName', async () => {
|
||||||
|
nock('https://sonarr.test')
|
||||||
|
.get('/api/v3/history')
|
||||||
|
.query(true)
|
||||||
|
.reply(200, { records: mockRecords });
|
||||||
|
|
||||||
|
const result = await fetchSonarrHistory(since);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].series._instanceUrl).toBe('https://sonarr.test');
|
||||||
|
expect(result[0].series._instanceName).toBe('Main Sonarr');
|
||||||
|
expect(result[0]._instanceName).toBe('Main Sonarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cached data on second call without making a new HTTP request', async () => {
|
||||||
|
nock('https://sonarr.test')
|
||||||
|
.get('/api/v3/history')
|
||||||
|
.query(true)
|
||||||
|
.reply(200, { records: mockRecords });
|
||||||
|
|
||||||
|
const first = await fetchSonarrHistory(since);
|
||||||
|
// Second call — nock would throw if a second request was made
|
||||||
|
const second = await fetchSonarrHistory(since);
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array and does not throw when instance errors', async () => {
|
||||||
|
nock('https://sonarr.test')
|
||||||
|
.get('/api/v3/history')
|
||||||
|
.query(true)
|
||||||
|
.replyWithError('ECONNREFUSED');
|
||||||
|
|
||||||
|
const result = await fetchSonarrHistory(since);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing records key gracefully', async () => {
|
||||||
|
nock('https://sonarr.test')
|
||||||
|
.get('/api/v3/history')
|
||||||
|
.query(true)
|
||||||
|
.reply(200, {});
|
||||||
|
|
||||||
|
const result = await fetchSonarrHistory(since);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchRadarrHistory', () => {
|
||||||
|
const mockRecords = [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
eventType: 'downloadFolderImported',
|
||||||
|
sourceTitle: 'My.Movie.2024',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
movie: { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [] },
|
||||||
|
movieId: 20
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
it('fetches records and tags them with _instanceUrl and _instanceName', async () => {
|
||||||
|
nock('https://radarr.test')
|
||||||
|
.get('/api/v3/history')
|
||||||
|
.query(true)
|
||||||
|
.reply(200, { records: mockRecords });
|
||||||
|
|
||||||
|
const result = await fetchRadarrHistory(since);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].movie._instanceUrl).toBe('https://radarr.test');
|
||||||
|
expect(result[0].movie._instanceName).toBe('Main Radarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array on network error', async () => {
|
||||||
|
nock('https://radarr.test')
|
||||||
|
.get('/api/v3/history')
|
||||||
|
.query(true)
|
||||||
|
.replyWithError('timeout');
|
||||||
|
|
||||||
|
const result = await fetchRadarrHistory(since);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalidateHistoryCache', () => {
|
||||||
|
it('forces a fresh fetch after invalidation', async () => {
|
||||||
|
nock('https://sonarr.test')
|
||||||
|
.get('/api/v3/history')
|
||||||
|
.query(true)
|
||||||
|
.reply(200, { records: [] });
|
||||||
|
|
||||||
|
await fetchSonarrHistory(since);
|
||||||
|
invalidateHistoryCache();
|
||||||
|
|
||||||
|
// Should make a second HTTP request — nock will satisfy it
|
||||||
|
nock('https://sonarr.test')
|
||||||
|
.get('/api/v3/history')
|
||||||
|
.query(true)
|
||||||
|
.reply(200, { records: [] });
|
||||||
|
|
||||||
|
const result = await fetchSonarrHistory(since);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(nock.isDone()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||