Compare commits

...

60 Commits

Author SHA1 Message Date
gronod 2137f65766 Merge develop into main for v0.1.4
Create Release / release (push) Successful in 14s
Build and Push Docker Image / build (push) Successful in 23s
2026-05-16 14:58:39 +01:00
gronod 0ddb7a407e chore: bump version to 0.1.4
Build and Push Docker Image / build (push) Successful in 30s
2026-05-16 14:58:17 +01:00
gronod 5ed547579d fix: improve mobile layout and prevent text overflow on small screens
Build and Push Docker Image / build (push) Successful in 32s
- download title: replace nowrap+ellipsis with break-word wrapping
- download header: flex-wrap so badges don't push off-screen
- path-item: word-break:break-all instead of nowrap overflow
- missing-text: allow wrapping instead of nowrap
- header-controls/user-info/admin-controls: full-width on mobile
- downloads-container: tighter padding on mobile
- status table: smaller padding + word-break on cache key codes
- timing row: narrower label/value columns on mobile
- import issue tooltip: constrained to viewport width, right-aligned
- ≤400px breakpoint: hide cover art, reduce app padding further
2026-05-16 14:55:43 +01:00
gronod 2bef9f9dee Merge main back into develop (v0.1.3)
Build and Push Docker Image / build (push) Successful in 32s
2026-05-16 00:32:24 +01:00
gronod fdecdd979b chore: bump version to 0.1.3
Build and Push Docker Image / build (push) Successful in 25s
2026-05-16 00:32:16 +01:00
gronod e97bd3c67b docs: comprehensive architecture documentation with PlantUML diagrams
- docs/ARCHITECTURE.md: full system overview, technology stack, directory
  structure, component architecture, data flow, auth, polling/caching,
  download matching pipeline, API reference, frontend architecture,
  configuration, deployment guide
- docs/diagrams/component.puml: system component diagram
- docs/diagrams/seq-auth.puml: authentication sequence diagram
- docs/diagrams/seq-dashboard.puml: dashboard request sequence diagram
- docs/diagrams/seq-polling.puml: background polling cycle sequence
- docs/diagrams/class-server.puml: server-side class/module diagram
- docs/diagrams/class-data.puml: data model / entity diagram
- docs/diagrams/state-ui.puml: frontend UI state diagram
- docs/diagrams/state-poller.puml: poller state diagram
- docs/diagrams/activity-matching.puml: download matching activity diagram
2026-05-16 00:32:16 +01:00
gronod 0c8d5d8a4a Revert "perf: split into fast poll + slow-cached library fetches"
This reverts commit 78a8737f29.
2026-05-16 00:32:16 +01:00
gronod 268238215e perf: split into fast poll + slow-cached library fetches
Fast poll (every cycle): SABnzbd, Sonarr/Radarr queue + history,
qBittorrent — all lightweight with no include* params.

Slow cache (5 min TTL): Sonarr series, Radarr movies, tags —
fetched only when cache expires. These rarely change.

This eliminates the 2s+ includeSeries/includeMovie joins from
every poll cycle. First poll is still slow (cold cache), but
subsequent polls should complete in <500ms.
2026-05-16 00:32:16 +01:00
gronod 31ff973eff perf: drop includeSeries/includeMovie from history fetches
Sonarr/Radarr history with include* params forces expensive DB
joins (~2s each). History records still have seriesId/movieId
for matching; the series/movie objects come from the queue-built
maps instead.

Trade-off: completed downloads only show if the series/movie
is also currently in the queue. Active downloads unaffected.
2026-05-16 00:32:16 +01:00
gronod 1327c8e466 perf: reduce history page sizes and drop includeEpisode
- Sonarr/Radarr history: pageSize 20 -> 10
- SABnzbd history: limit 20 -> 10
- Drop includeEpisode from Sonarr queue and history (never rendered)
- These reduce DB join overhead and response payload size
2026-05-16 00:32:16 +01:00
gronod c10d20d9f5 fix: crash from stale references to removed sonarrSeries/radarrMovies
Debug logging at line 389/393 still referenced radarrMovies.data and
sonarrSeries.data which were removed in the previous commit. Updated
to use moviesMap/seriesMap built from embedded queue/history objects.
2026-05-16 00:32:16 +01:00
gronod d50a6fe19c perf: eliminate full Sonarr Series + Radarr Movies library fetches
The poller was fetching the entire series and movie libraries on every
poll cycle (~9s each). Queue and history records already embed the full
series/movie object via includeSeries/includeMovie params.

Changes:
- Remove 'Sonarr Series' and 'Radarr Movies' timed fetches from poller
- Tag queue/history records with _instanceUrl in the poller instead
- Build seriesMap/moviesMap from embedded objects in dashboard
- Remove poll:sonarr-series and poll:radarr-movies cache keys
- Fix missing axios and config imports in dashboard
- Net result: ~18s saved per poll cycle, ~2 fewer API calls
2026-05-16 00:32:16 +01:00
gronod 6e3a98ae75 feat: status page shows effective refresh mode across all active clients
- Server tracks each client's refresh rate via query param on /user-downloads
- Active clients expire after 30s of no requests
- Status panel 'Data Refresh' card shows:
  - Background poll interval (or Disabled)
  - Effective mode: Background if all clients >= poll rate,
    Foreground (with rate) if any client is faster, Idle if no clients
  - Active client list with per-user refresh rate and last-seen age
- Foreground mode shown with orange badge for visibility
- Client refresh rate sent on every dashboard request
2026-05-16 00:32:16 +01:00
gronod 57e1db18e2 feat: live-updating status panel with per-task poll timings
- Each service fetch is individually timed (SABnzbd, Sonarr, Radarr, qBit)
- Status panel shows timing bar chart with ms per task and total
- Shows 'Last Poll' age that updates live
- Shows client refresh rate (1s/5s/10s/Off)
- Status panel auto-refreshes in sync with dashboard refresh cycle
- Changing refresh rate restarts the status panel refresh too
- TTL counters update live on each refresh
2026-05-16 00:32:16 +01:00
gronod c03e4620ea feat: add admin-only status page with cache stats
- New /api/dashboard/status endpoint (admin-only, 403 for non-admins)
- Returns server info (uptime, Node version, memory usage)
- Returns polling mode and interval
- Returns cache stats: entry count, total size, per-key breakdown
  with item count, size in KB, and TTL remaining
- Status button in admin controls header
- Collapsible status panel with grid layout
- Responsive: single column on mobile
2026-05-16 00:32:16 +01:00
gronod e5b2fc8ea4 docs: add POLL_INTERVAL to README, .env.sample, and .env.example
- Document background polling and on-demand mode in README
- Add POLL_INTERVAL to all config examples (Docker, Compose, .env)
- New 'Background Polling' section in Features
- Sanitize leaked credentials in .env.example
2026-05-16 00:32:16 +01:00
gronod 85bac5994e feat: make background polling disablable with on-demand fallback
- Set POLL_INTERVAL=0, off, false, or disabled to disable background polling
- When disabled, data is fetched on-demand when a user opens the dashboard
- On-demand results cached for 30s so other users benefit from fresh data
- A user with a faster refresh rate keeps the cache warm for everyone
- When polling is enabled, behaviour is unchanged (default 5s)
2026-05-16 00:32:16 +01:00
gronod f28d94d9a3 perf: background poller for near-instant dashboard responses
- New poller.js polls all services on a configurable interval
- POLL_INTERVAL env var (default 5000ms / 5 seconds)
- All data stored in cache with TTL of 3x poll interval
- Dashboard endpoint now reads from cache only (no network calls)
- API responses are near-instant regardless of service count
- First poll runs immediately on server start
2026-05-16 00:32:16 +01:00
gronod 1574cce788 perf: reduce history page size from 100 to 20
- SABnzbd, Sonarr, and Radarr history now fetch 20 items instead of 100
- Only recent completions are needed for the dashboard
- Reduces response payload and serialization time
2026-05-16 00:32:16 +01:00
gronod b48332f075 perf: persist qBittorrent clients between requests
- Reuse client instances so auth cookies survive across requests
- Eliminates redundant login round-trips on every dashboard refresh
- Clients still re-authenticate automatically if session expires (403)
2026-05-16 00:32:16 +01:00
gronod 3edc98b8d6 perf: cache slow-changing data (series, movies, tags) with 60s TTL
- Add MemoryCache utility with get/set/invalidate/clear
- Cache Sonarr series, Sonarr tags, Radarr movies, Radarr tags
- 60-second TTL - first request fetches, subsequent requests served from cache
- Queue, history, and torrent data remain uncached (changes frequently)
- On cache hit, these 4 heavy API calls resolve instantly
2026-05-16 00:32:16 +01:00
gronod c6b5aaf3de feat: show import-pending red lozenge when Sonarr/Radarr has issues
- Detect trackedDownloadState=importPending or status=warning/error
- Extract statusMessages and errorMessage from queue records
- Display red 'Import Pending' badge on download card header
- Hover reveals tooltip with the specific issue messages
- Visible to all users (not admin-only)
2026-05-16 00:32:16 +01:00
gronod c0478ed1b2 feat: revise login screen with logo and smooth transition to splash
- Replace 'Login to Emby' heading with sofarr logo and subtitle
- Subtitle reads 'Login with your Emby credentials'
- Login form fades out smoothly before splash screen appears
- Form labels remain left-aligned within centered login box
2026-05-16 00:32:16 +01:00
gronod b146a180d0 feat: add splash screen with logo on app load and after login
- Show sofarr logo splash screen while app initialises
- On page load: splash stays visible while checking auth and fetching data
- After login: splash reappears while fetching initial downloads
- Minimum 1.2s display with smooth fade-out transition
- Subtle pulse animation on the logo
2026-05-16 00:32:16 +01:00
gronod bafa03aac2 fix: handle Ombi-mangled tags for email-style usernames
- Replicate Ombi's SanitizeTagLabel logic (lowercase, replace non-alnum with hyphen, collapse, trim)
- Try exact tag match first (handles users with normal usernames)
- Fall back to sanitized comparison (handles email usernames like robcunn@live.co.uk → robcunn-live-co-uk)
- Applied to all tag matching locations
2026-05-16 00:32:16 +01:00
gronod 59b096a60a feat: link series/movie titles to Sonarr/Radarr for admin users
- Series title links to Sonarr series page (/series/{titleSlug})
- Movie title links to Radarr movie page (/movie/{titleSlug})
- Links open in new tab, only shown for admin users
- Instance URL preserved through data aggregation for multi-instance support
2026-05-16 00:32:16 +01:00
gronod d09b0ab40a fix: show full download path (content_path) for qBittorrent
- Prefer content_path over save_path for qBittorrent torrents
- content_path is the full path to the single file or top-level
  folder for multi-file torrents
- save_path is just the base download directory
2026-05-16 00:32:16 +01:00
gronod 137d40affe ci: remove arm builds, amd64 only for now 2026-05-16 00:32:16 +01:00
gronod 84e4201dc1 ci: build develop tag on every push to develop branch
- Triggers on develop branch in addition to release/* branches
- Develop pushes get tagged as :develop only
- Release pushes continue to get :version, :release, and :latest tags
2026-05-16 00:32:16 +01:00
gronod b75cd18580 feat: show download/target paths for admin users
- Admin users see download path (SABnzbd storage / qBittorrent save_path)
- Admin users see target path (Sonarr series folder / Radarr movie folder)
- Paths displayed in monospace font at bottom of card details
- Non-admin users unaffected (paths not sent in API response)
2026-05-16 00:32:16 +01:00
gronod 36d183cba9 docs: comprehensive architecture documentation with PlantUML diagrams
Build and Push Docker Image / build (push) Successful in 23s
- docs/ARCHITECTURE.md: full system overview, technology stack, directory
  structure, component architecture, data flow, auth, polling/caching,
  download matching pipeline, API reference, frontend architecture,
  configuration, deployment guide
- docs/diagrams/component.puml: system component diagram
- docs/diagrams/seq-auth.puml: authentication sequence diagram
- docs/diagrams/seq-dashboard.puml: dashboard request sequence diagram
- docs/diagrams/seq-polling.puml: background polling cycle sequence
- docs/diagrams/class-server.puml: server-side class/module diagram
- docs/diagrams/class-data.puml: data model / entity diagram
- docs/diagrams/state-ui.puml: frontend UI state diagram
- docs/diagrams/state-poller.puml: poller state diagram
- docs/diagrams/activity-matching.puml: download matching activity diagram
2026-05-16 00:30:38 +01:00
gronod b1f81eff0f Revert "perf: split into fast poll + slow-cached library fetches"
Build and Push Docker Image / build (push) Successful in 24s
This reverts commit 78a8737f29.
2026-05-16 00:21:46 +01:00
gronod 78a8737f29 perf: split into fast poll + slow-cached library fetches
Build and Push Docker Image / build (push) Successful in 24s
Fast poll (every cycle): SABnzbd, Sonarr/Radarr queue + history,
qBittorrent — all lightweight with no include* params.

Slow cache (5 min TTL): Sonarr series, Radarr movies, tags —
fetched only when cache expires. These rarely change.

This eliminates the 2s+ includeSeries/includeMovie joins from
every poll cycle. First poll is still slow (cold cache), but
subsequent polls should complete in <500ms.
2026-05-16 00:20:11 +01:00
gronod d5542abd27 perf: drop includeSeries/includeMovie from history fetches
Build and Push Docker Image / build (push) Successful in 23s
Sonarr/Radarr history with include* params forces expensive DB
joins (~2s each). History records still have seriesId/movieId
for matching; the series/movie objects come from the queue-built
maps instead.

Trade-off: completed downloads only show if the series/movie
is also currently in the queue. Active downloads unaffected.
2026-05-16 00:16:31 +01:00
gronod 980e20247c perf: reduce history page sizes and drop includeEpisode
Build and Push Docker Image / build (push) Successful in 22s
- Sonarr/Radarr history: pageSize 20 -> 10
- SABnzbd history: limit 20 -> 10
- Drop includeEpisode from Sonarr queue and history (never rendered)
- These reduce DB join overhead and response payload size
2026-05-16 00:14:12 +01:00
gronod ba43a3d6bd fix: crash from stale references to removed sonarrSeries/radarrMovies
Build and Push Docker Image / build (push) Successful in 33s
Debug logging at line 389/393 still referenced radarrMovies.data and
sonarrSeries.data which were removed in the previous commit. Updated
to use moviesMap/seriesMap built from embedded queue/history objects.
2026-05-16 00:11:24 +01:00
gronod b44d370b51 perf: eliminate full Sonarr Series + Radarr Movies library fetches
Build and Push Docker Image / build (push) Successful in 40s
The poller was fetching the entire series and movie libraries on every
poll cycle (~9s each). Queue and history records already embed the full
series/movie object via includeSeries/includeMovie params.

Changes:
- Remove 'Sonarr Series' and 'Radarr Movies' timed fetches from poller
- Tag queue/history records with _instanceUrl in the poller instead
- Build seriesMap/moviesMap from embedded objects in dashboard
- Remove poll:sonarr-series and poll:radarr-movies cache keys
- Fix missing axios and config imports in dashboard
- Net result: ~18s saved per poll cycle, ~2 fewer API calls
2026-05-16 00:05:14 +01:00
gronod 6e81180175 feat: status page shows effective refresh mode across all active clients
Build and Push Docker Image / build (push) Successful in 41s
- Server tracks each client's refresh rate via query param on /user-downloads
- Active clients expire after 30s of no requests
- Status panel 'Data Refresh' card shows:
  - Background poll interval (or Disabled)
  - Effective mode: Background if all clients >= poll rate,
    Foreground (with rate) if any client is faster, Idle if no clients
  - Active client list with per-user refresh rate and last-seen age
- Foreground mode shown with orange badge for visibility
- Client refresh rate sent on every dashboard request
2026-05-16 00:00:08 +01:00
gronod 5ae6af114e feat: live-updating status panel with per-task poll timings
Build and Push Docker Image / build (push) Successful in 28s
- Each service fetch is individually timed (SABnzbd, Sonarr, Radarr, qBit)
- Status panel shows timing bar chart with ms per task and total
- Shows 'Last Poll' age that updates live
- Shows client refresh rate (1s/5s/10s/Off)
- Status panel auto-refreshes in sync with dashboard refresh cycle
- Changing refresh rate restarts the status panel refresh too
- TTL counters update live on each refresh
2026-05-15 23:58:10 +01:00
gronod c97f232290 feat: add admin-only status page with cache stats
Build and Push Docker Image / build (push) Successful in 29s
- New /api/dashboard/status endpoint (admin-only, 403 for non-admins)
- Returns server info (uptime, Node version, memory usage)
- Returns polling mode and interval
- Returns cache stats: entry count, total size, per-key breakdown
  with item count, size in KB, and TTL remaining
- Status button in admin controls header
- Collapsible status panel with grid layout
- Responsive: single column on mobile
2026-05-15 23:52:32 +01:00
gronod 6d35ce06e0 docs: add POLL_INTERVAL to README, .env.sample, and .env.example
Build and Push Docker Image / build (push) Successful in 31s
- Document background polling and on-demand mode in README
- Add POLL_INTERVAL to all config examples (Docker, Compose, .env)
- New 'Background Polling' section in Features
- Sanitize leaked credentials in .env.example
2026-05-15 23:48:46 +01:00
gronod 6d6969190a feat: make background polling disablable with on-demand fallback
Build and Push Docker Image / build (push) Successful in 27s
- Set POLL_INTERVAL=0, off, false, or disabled to disable background polling
- When disabled, data is fetched on-demand when a user opens the dashboard
- On-demand results cached for 30s so other users benefit from fresh data
- A user with a faster refresh rate keeps the cache warm for everyone
- When polling is enabled, behaviour is unchanged (default 5s)
2026-05-15 23:46:51 +01:00
gronod fe73589633 perf: background poller for near-instant dashboard responses
Build and Push Docker Image / build (push) Successful in 28s
- New poller.js polls all services on a configurable interval
- POLL_INTERVAL env var (default 5000ms / 5 seconds)
- All data stored in cache with TTL of 3x poll interval
- Dashboard endpoint now reads from cache only (no network calls)
- API responses are near-instant regardless of service count
- First poll runs immediately on server start
2026-05-15 23:42:38 +01:00
gronod 6baf643645 perf: reduce history page size from 100 to 20
Build and Push Docker Image / build (push) Successful in 23s
- SABnzbd, Sonarr, and Radarr history now fetch 20 items instead of 100
- Only recent completions are needed for the dashboard
- Reduces response payload and serialization time
2026-05-15 23:36:43 +01:00
gronod 570eca0b82 perf: persist qBittorrent clients between requests
Build and Push Docker Image / build (push) Successful in 27s
- Reuse client instances so auth cookies survive across requests
- Eliminates redundant login round-trips on every dashboard refresh
- Clients still re-authenticate automatically if session expires (403)
2026-05-15 23:35:16 +01:00
gronod b04b52e3f1 perf: cache slow-changing data (series, movies, tags) with 60s TTL
Build and Push Docker Image / build (push) Successful in 27s
- Add MemoryCache utility with get/set/invalidate/clear
- Cache Sonarr series, Sonarr tags, Radarr movies, Radarr tags
- 60-second TTL - first request fetches, subsequent requests served from cache
- Queue, history, and torrent data remain uncached (changes frequently)
- On cache hit, these 4 heavy API calls resolve instantly
2026-05-15 23:32:45 +01:00
gronod eda9770f49 feat: show import-pending red lozenge when Sonarr/Radarr has issues
Build and Push Docker Image / build (push) Successful in 23s
- Detect trackedDownloadState=importPending or status=warning/error
- Extract statusMessages and errorMessage from queue records
- Display red 'Import Pending' badge on download card header
- Hover reveals tooltip with the specific issue messages
- Visible to all users (not admin-only)
2026-05-15 23:23:25 +01:00
gronod efa66b9fd6 feat: revise login screen with logo and smooth transition to splash
Build and Push Docker Image / build (push) Successful in 25s
- Replace 'Login to Emby' heading with sofarr logo and subtitle
- Subtitle reads 'Login with your Emby credentials'
- Login form fades out smoothly before splash screen appears
- Form labels remain left-aligned within centered login box
2026-05-15 23:16:10 +01:00
gronod fd8335b683 feat: add splash screen with logo on app load and after login
Build and Push Docker Image / build (push) Successful in 29s
- Show sofarr logo splash screen while app initialises
- On page load: splash stays visible while checking auth and fetching data
- After login: splash reappears while fetching initial downloads
- Minimum 1.2s display with smooth fade-out transition
- Subtle pulse animation on the logo
2026-05-15 23:14:16 +01:00
gronod 4c1b11c3cc fix: handle Ombi-mangled tags for email-style usernames
Build and Push Docker Image / build (push) Successful in 27s
- Replicate Ombi's SanitizeTagLabel logic (lowercase, replace non-alnum with hyphen, collapse, trim)
- Try exact tag match first (handles users with normal usernames)
- Fall back to sanitized comparison (handles email usernames like robcunn@live.co.uk → robcunn-live-co-uk)
- Applied to all tag matching locations
2026-05-15 21:46:43 +01:00
gronod c6d1a7ffed feat: link series/movie titles to Sonarr/Radarr for admin users
Build and Push Docker Image / build (push) Successful in 25s
- Series title links to Sonarr series page (/series/{titleSlug})
- Movie title links to Radarr movie page (/movie/{titleSlug})
- Links open in new tab, only shown for admin users
- Instance URL preserved through data aggregation for multi-instance support
2026-05-15 21:34:01 +01:00
gronod 9b0e778392 fix: show full download path (content_path) for qBittorrent
Build and Push Docker Image / build (push) Successful in 20s
- Prefer content_path over save_path for qBittorrent torrents
- content_path is the full path to the single file or top-level
  folder for multi-file torrents
- save_path is just the base download directory
2026-05-15 21:29:16 +01:00
gronod d31f108821 ci: remove arm builds, amd64 only for now
Build and Push Docker Image / build (push) Successful in 28s
2026-05-15 21:14:40 +01:00
gronod b590513f94 ci: build develop tag on every push to develop branch
Build and Push Docker Image / build (push) Has been cancelled
- Triggers on develop branch in addition to release/* branches
- Develop pushes get tagged as :develop only
- Release pushes continue to get :version, :release, and :latest tags
2026-05-15 21:10:03 +01:00
gronod ebb73492c4 feat: show download/target paths for admin users
- Admin users see download path (SABnzbd storage / qBittorrent save_path)
- Admin users see target path (Sonarr series folder / Radarr movie folder)
- Paths displayed in monospace font at bottom of card details
- Non-admin users unaffected (paths not sent in API response)
2026-05-15 21:07:19 +01:00
Gandalf 8b526aa13b Merge pull request 'ci: build multi-arch images (amd64, arm64, arm/v7)' (#2) from develop into main
Reviewed-on: #2
2026-05-15 20:55:54 +01:00
gronod 67b816cd61 ci: build multi-arch images (amd64, arm64, arm/v7)
- Add QEMU for cross-platform emulation
- Add Docker Buildx for multi-platform builds
- Build for linux/amd64, linux/arm64, linux/arm/v7
2026-05-15 20:53:37 +01:00
gronod faaca310e9 chore: bump version to 0.1.2
Build and Push Docker Image / build (push) Successful in 4m20s
Create Release / release (push) Successful in 1m47s
2026-05-15 20:48:40 +01:00
gronod 0957f83411 feat: admin users can view all downloads with user badges
- Admin users (Emby IsAdministrator) see a 'Show all users' toggle
- When toggled, all tagged downloads are shown regardless of user
- Each download card shows the tagged user's name as a badge
- Non-admin users see only their own downloads (unchanged behavior)
- Backend accepts ?showAll=true query param (admin-only)
2026-05-15 20:46:56 +01:00
gronod 6463c6b3d1 docs: add docker-compose.yaml with sample configuration 2026-05-15 20:41:46 +01:00
26 changed files with 2978 additions and 264 deletions
+6 -4
View File
@@ -1,5 +1,10 @@
# Server Configuration # Server Configuration
PORT=3001 PORT=3001
LOG_LEVEL=info
# Background polling interval in ms (default: 5000)
# Set to 0 or "off" to disable and fetch on-demand instead
# POLL_INTERVAL=5000
# Emby Configuration (single instance) # Emby Configuration (single instance)
EMBY_URL=http://localhost:8096 EMBY_URL=http://localhost:8096
@@ -16,7 +21,4 @@ SONARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:8989", "apiKey":
RADARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:7878", "apiKey": "your_api_key"}] RADARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:7878", "apiKey": "your_api_key"}]
# qBittorrent Instances (JSON array) # qBittorrent Instances (JSON array)
QBITTORRENT_INSTANCES=[ QBITTORRENT_INSTANCES=[{"name": "main", "url": "http://localhost:8080", "username": "admin", "password": "your_password"}]
{"name": "ransackedcrew", "url": "https://qbittorrent.ransackedcrew.info", "username": "gronod", "password": "K32D&JDjtHA&mC"},
{"name": "i3omb", "url": "https://qbittorrent.i3omb.com", "username": "admin", "password": "b053288369XX!"}
]
+9
View File
@@ -14,6 +14,14 @@ PORT=3001
# - silent: No logging # - silent: No logging
LOG_LEVEL=info LOG_LEVEL=info
# Background polling interval in milliseconds (default: 5000)
# sofarr polls all services in the background and caches results so
# dashboard requests are near-instant.
# Set to 0, "off", "false", or "disabled" to disable background polling.
# When disabled, data is fetched on-demand when a user opens the dashboard
# and cached for 30 seconds so other users benefit from the same fetch.
# POLL_INTERVAL=5000
# ============================================================================= # =============================================================================
# EMBY (Authentication - Required) # EMBY (Authentication - Required)
# ============================================================================= # =============================================================================
@@ -74,4 +82,5 @@ RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"yo
# 3. URLs should include protocol (http:// or https://) # 3. URLs should include protocol (http:// or https://)
# 4. For qBittorrent, ensure Web UI is enabled in settings # 4. For qBittorrent, ensure Web UI is enabled in settings
# 5. User downloads are matched by tags in Sonarr/Radarr - tag your media! # 5. User downloads are matched by tags in Sonarr/Radarr - tag your media!
# 6. Background polling keeps data fresh; disable it for low-resource setups
# ============================================================================= # =============================================================================
+17 -10
View File
@@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- 'release/**' - 'release/**'
- 'develop'
jobs: jobs:
build: build:
@@ -12,25 +13,31 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Extract version from package.json - name: Compute image tags
id: version id: meta
run: | run: |
VERSION=$(node -p "require('./package.json').version") VERSION=$(node -p "require('./package.json').version")
BRANCH=${GITHUB_REF#refs/heads/} BRANCH=${GITHUB_REF#refs/heads/}
RELEASE_NAME=${BRANCH#release/}
echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "release=${RELEASE_NAME}" >> $GITHUB_OUTPUT
echo "Building version ${VERSION} from branch ${BRANCH}" if [ "$BRANCH" = "develop" ]; then
echo "tags=reg.i3omb.com/sofarr:develop" >> $GITHUB_OUTPUT
echo "Building develop image (version ${VERSION})"
else
RELEASE_NAME=${BRANCH#release/}
TAGS="reg.i3omb.com/sofarr:${VERSION}"
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "Building release image ${VERSION} from branch ${BRANCH}"
fi
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
tags: | tags: ${{ steps.meta.outputs.tags }}
reg.i3omb.com/sofarr:${{ steps.version.outputs.version }}
reg.i3omb.com/sofarr:${{ steps.version.outputs.release }}
reg.i3omb.com/sofarr:latest
labels: | labels: |
org.opencontainers.image.version=${{ steps.version.outputs.version }} org.opencontainers.image.version=${{ steps.meta.outputs.version }}
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }} org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
+16
View File
@@ -108,6 +108,7 @@ docker run -d \
-e SABNZBD_INSTANCES='[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]' \ -e SABNZBD_INSTANCES='[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]' \
-e QBITTORRENT_INSTANCES='[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]' \ -e QBITTORRENT_INSTANCES='[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]' \
-e LOG_LEVEL=info \ -e LOG_LEVEL=info \
-e POLL_INTERVAL=5000 \
docker.i3omb.com/sofarr:latest docker.i3omb.com/sofarr:latest
``` ```
@@ -130,6 +131,7 @@ services:
- SABNZBD_INSTANCES=[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}] - SABNZBD_INSTANCES=[{"name":"main","url":"http://sabnzbd:8080","apiKey":"your-key"}]
- QBITTORRENT_INSTANCES=[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}] - QBITTORRENT_INSTANCES=[{"name":"main","url":"http://qbit:8080","username":"admin","password":"pass"}]
- LOG_LEVEL=info - LOG_LEVEL=info
- POLL_INTERVAL=5000
``` ```
> **Tip:** You can also use a combination — mount a `.env` file for base config, and override specific values with `-e` flags. Environment variables always take precedence. > **Tip:** You can also use a combination — mount a `.env` file for base config, and override specific values with `-e` flags. Environment variables always take precedence.
@@ -181,6 +183,8 @@ Open `http://localhost:3001` in your browser
```bash ```bash
PORT=3001 # Server port PORT=3001 # Server port
LOG_LEVEL=info # Logging: debug, info, warn, error, silent LOG_LEVEL=info # Logging: debug, info, warn, error, silent
POLL_INTERVAL=5000 # Background polling interval in ms (default: 5000)
# Set to 0 or "off" to disable (on-demand mode)
``` ```
### Service Instances (JSON Array Format) ### Service Instances (JSON Array Format)
@@ -231,6 +235,18 @@ To see your downloads, you need to tag your media in Sonarr/Radarr:
## Features in Detail ## Features in Detail
### Background Polling
sofarr polls all configured services in the background and caches the results. Dashboard requests read from cache, making them near-instant regardless of how many services you have.
| Setting | Behaviour |
|---------|----------|
| `POLL_INTERVAL=5000` (default) | Background poller fetches every 5s. Dashboard reads from cache instantly. |
| `POLL_INTERVAL=10000` | Poll every 10 seconds. |
| `POLL_INTERVAL=0` / `off` / `disabled` | No background polling. Data fetched on-demand when a user opens the dashboard, then cached for 30 seconds. |
**On-demand mode** is useful for low-resource setups. When one user's browser refreshes, the fetched data is cached and served to all other users until it expires. A user with a faster refresh rate effectively keeps the cache warm for everyone.
### Real-Time Updates ### Real-Time Updates
- Auto-refresh every 5 seconds (configurable: 1s, 5s, 10s, or off) - Auto-refresh every 5 seconds (configurable: 1s, 5s, 10s, or off)
- In-place DOM updates for smooth UI (no flickering) - In-place DOM updates for smooth UI (no flickering)
+17
View File
@@ -0,0 +1,17 @@
version: "3"
services:
sofarr:
image: docker.i3omb.com/sofarr:latest
container_name: sofarr
restart: unless-stopped
ports:
- "3001:3001"
environment:
- PORT=3001
- EMBY_URL=https://emby.example.com
- EMBY_API_KEY=your-emby-api-key
- SONARR_INSTANCES=[{"name":"main","url":"https://sonarr.example.com","apiKey":"your-sonarr-api-key"}]
- RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"your-radarr-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"}]
- LOG_LEVEL=info
+609
View File
@@ -0,0 +1,609 @@
# sofarr — Architecture Documentation
Comprehensive technical documentation covering the full architecture of the **sofarr** application: a personal media download dashboard that aggregates downloads from SABnzbd, Sonarr, Radarr, and qBittorrent, filtered by Emby user identity.
---
## Table of Contents
1. [System Overview](#1-system-overview)
2. [Technology Stack](#2-technology-stack)
3. [Directory Structure](#3-directory-structure)
4. [Component Architecture](#4-component-architecture)
5. [Data Flow](#5-data-flow)
6. [Authentication & Authorisation](#6-authentication--authorisation)
7. [Background Polling & Caching](#7-background-polling--caching)
8. [Download Matching Pipeline](#8-download-matching-pipeline)
9. [API Reference](#9-api-reference)
10. [Frontend Architecture](#10-frontend-architecture)
11. [Configuration](#11-configuration)
12. [Deployment](#12-deployment)
13. [UML Diagrams (PlantUML)](#13-uml-diagrams-plantuml)
---
## 1. System Overview
sofarr is a **single-page web application** with a Node.js/Express backend. It provides a personalised view of media downloads by:
1. **Authenticating** users against an Emby/Jellyfin media server.
2. **Aggregating** download data from multiple *arr service instances and download clients.
3. **Filtering** downloads by user — each user only sees media tagged with their username in Sonarr/Radarr.
4. **Presenting** a real-time dashboard with progress, speeds, cover art, and status.
Admin users can view all users' downloads, see server status, cache statistics, and poll timings.
### High-Level Architecture
```
┌─────────────────────────────────────────────────────┐
│ Browser (SPA) │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ Login │ │Dashboard │ │ Status Panel │ │
│ │ Form │ │ Cards │ │ (Admin only) │ │
│ └────┬─────┘ └────┬─────┘ └───────┬───────────┘ │
│ │ │ │ │
└───────┼──────────────┼────────────────┼──────────────┘
│ POST /login │ GET /user- │ GET /status
│ │ downloads │
▼ ▼ ▼
┌─────────────────────────────────────────────────────┐
│ Express Server (:3001) │
│ ┌────────┐ ┌──────────┐ ┌────────┐ ┌────────────┐ │
│ │ Auth │ │Dashboard │ │ Emby │ │ Static │ │
│ │ Routes │ │ Routes │ │ Routes │ │ Files │ │
│ └────┬───┘ └────┬─────┘ └────┬───┘ └────────────┘ │
│ │ │ │ │
│ ┌────┴──────────┴────────────┴──────────────────┐ │
│ │ Utilities Layer │ │
│ │ ┌────────┐ ┌────────┐ ┌──────┐ ┌──────────┐ │ │
│ │ │ Poller │ │ Cache │ │Config│ │qBittorrent│ │ │
│ │ └───┬────┘ └────────┘ └──────┘ └──────────┘ │ │
│ └──────┼────────────────────────────────────────┘ │
└─────────┼────────────────────────────────────────────┘
│ HTTP/API calls
┌──────────────────────────────────────────────────────┐
│ External Services │
│ ┌──────────┐ ┌────────┐ ┌────────┐ ┌────────────┐ │
│ │ SABnzbd │ │ Sonarr │ │ Radarr │ │qBittorrent │ │
│ │ (Usenet) │ │ (TV) │ │(Movie) │ │ (Torrent) │ │
│ └──────────┘ └────────┘ └────────┘ └────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Emby / Jellyfin │ │
│ │ (Authentication + User DB) │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
```
---
## 2. Technology Stack
| Layer | Technology | Purpose |
|-------|-----------|---------|
| **Runtime** | Node.js 18+ | Server runtime |
| **Framework** | Express 4.x | HTTP server, routing, middleware |
| **HTTP Client** | axios 1.x | External API communication |
| **Frontend** | Vanilla JS + CSS | Single-page app, no build step |
| **Auth** | Emby API + httpOnly cookies | Session management |
| **Caching** | In-memory Map with TTL | Reduce external API load |
| **Scheduling** | `setInterval` | Background polling |
| **Containerisation** | Docker (Alpine) | Production deployment |
| **Logging** | Custom logger + `console.*` | File + stdout logging with levels |
---
## 3. Directory Structure
```
sofarr/
├── server/ # Backend application
│ ├── index.js # Entry point: Express setup, middleware, startup
│ ├── routes/
│ │ ├── auth.js # POST /login, GET /me, POST /logout
│ │ ├── dashboard.js # GET /user-downloads, /user-summary, /status
│ │ ├── emby.js # Proxy routes to Emby API
│ │ ├── sabnzbd.js # Proxy routes to SABnzbd API
│ │ ├── sonarr.js # Proxy routes to Sonarr API
│ │ └── radarr.js # Proxy routes to Radarr API
│ └── utils/
│ ├── cache.js # MemoryCache class (Map + TTL + stats)
│ ├── config.js # Multi-instance service configuration parser
│ ├── logger.js # File logger (server.log)
│ ├── poller.js # Background polling engine + timing
│ └── qbittorrent.js # qBittorrent client with auth + torrent mapping
├── public/ # Static frontend (served by Express)
│ ├── index.html # HTML shell: splash, login, dashboard
│ ├── app.js # All frontend logic (auth, rendering, status)
│ ├── style.css # Themes, layout, responsive design
│ └── images/ # Logo / splash screen assets
├── Dockerfile # Production container image
├── docker-compose.yaml # Example compose deployment
├── package.json # Dependencies and scripts
├── .env.sample # Annotated environment variable template
└── README.md # User-facing documentation
```
---
## 4. Component Architecture
### 4.1 Server Entry Point (`server/index.js`)
Responsibilities:
- Load environment variables via `dotenv`
- Configure structured logging with level filtering (`LOG_LEVEL`)
- Redirect `console.*` to both stdout and `server.log`
- Mount Express middleware (CORS, cookie-parser, JSON, static files)
- Mount route modules under `/api/*`
- Start the background poller
### 4.2 Route Modules
| Module | Mount Point | Auth Required | Purpose |
|--------|------------|---------------|---------|
| `auth.js` | `/api/auth` | No | Login, session check, logout |
| `dashboard.js` | `/api/dashboard` | Yes (cookie) | Aggregated download data, status |
| `emby.js` | `/api/emby` | No | Proxy to Emby API |
| `sabnzbd.js` | `/api/sabnzbd` | No | Proxy to SABnzbd API |
| `sonarr.js` | `/api/sonarr` | No | Proxy to Sonarr API |
| `radarr.js` | `/api/radarr` | No | Proxy to Radarr API |
> **Note:** The proxy routes (`emby`, `sabnzbd`, `sonarr`, `radarr`) use legacy single-instance env vars and are largely unused by the dashboard — they exist for direct API access. The dashboard reads from the poller cache.
### 4.3 Utility Modules
**`config.js`** — Parses service instances from environment variables. Supports both JSON array format (`SONARR_INSTANCES=[{...}]`) and legacy single-instance format (`SONARR_URL` + `SONARR_API_KEY`). Each instance gets an `id` derived from `name` or index.
**`cache.js`** — Singleton `MemoryCache` backed by a `Map`. Each entry has an expiration timestamp. Provides `get`, `set`, `invalidate`, `clear`, and `getStats` (returns per-key size, item count, TTL remaining).
**`poller.js`** — Background polling engine. Fetches data from all configured service instances in parallel with per-task timing. Stores results in the cache with a configurable TTL. Can be disabled entirely (`POLL_INTERVAL=0`), in which case data is fetched on-demand per dashboard request.
**`qbittorrent.js`** — `QBittorrentClient` class with cookie-based authentication, automatic re-auth on 403, and persistent client instances. Includes torrent-to-download mapping (`mapTorrentToDownload`) and formatting utilities.
**`logger.js`** — Simple file appender writing timestamped messages to `server.log`.
---
## 5. Data Flow
### 5.1 Polling Cycle
Every `POLL_INTERVAL` ms (default 5000), the poller fetches from all services in parallel:
| Task | API Call | Params |
|------|----------|--------|
| SABnzbd Queue | `GET /api?mode=queue` | `output=json` |
| SABnzbd History | `GET /api?mode=history` | `limit=10` |
| Sonarr Tags | `GET /api/v3/tag` | — |
| Sonarr Queue | `GET /api/v3/queue` | `includeSeries=true` |
| Sonarr History | `GET /api/v3/history` | `pageSize=10` |
| Radarr Queue | `GET /api/v3/queue` | `includeMovie=true` |
| Radarr History | `GET /api/v3/history` | `pageSize=10` |
| Radarr Tags | `GET /api/v3/tag` | — |
| qBittorrent | `GET /api/v2/torrents/info` | — |
Results are stored in the cache under `poll:*` keys with a TTL of `POLL_INTERVAL × 3`.
### 5.2 Dashboard Request
When a user requests `/api/dashboard/user-downloads`:
1. Read all `poll:*` keys from cache
2. Build `seriesMap` and `moviesMap` from embedded objects in queue records
3. Build `sonarrTagMap` and `radarrTagMap` from tag data
4. For each SABnzbd queue slot → try to match against Sonarr/Radarr queue records by title
5. For each SABnzbd history slot → try to match against Sonarr/Radarr history records
6. For each qBittorrent torrent → try to match against Sonarr/Radarr queue, then history
7. For each match, resolve the series/movie, extract the user tag, check if it belongs to the requesting user
8. Return only the user's downloads (or all, if admin with `showAll=true`)
---
## 6. Authentication & Authorisation
### Flow
1. User submits credentials via the login form
2. Backend calls Emby `POST /Users/authenticatebyname`
3. On success, fetches full user profile to determine admin status
4. Sets an `httpOnly` cookie (`emby_user`) containing: `{ id, name, isAdmin, token }`
5. Cookie expires after 24 hours
6. All subsequent dashboard requests read this cookie for identity
### Authorisation Matrix
| Feature | Regular User | Admin |
|---------|:----------:|:-----:|
| View own downloads | ✓ | ✓ |
| View all users' downloads | ✗ | ✓ (`showAll`) |
| See download/target paths | ✗ | ✓ |
| See Sonarr/Radarr links | ✗ | ✓ |
| View status panel | ✗ | ✓ |
### Tag Matching
Users are matched to downloads via tags in Sonarr/Radarr:
1. **Exact match**: tag label (lowercased) === username (lowercased)
2. **Sanitised match**: handles Ombi's tag mangling — `sanitizeTagLabel()` converts to lowercase, replaces non-alphanumeric with hyphens, collapses, trims
---
## 7. Background Polling & Caching
### Polling Modes
| Mode | `POLL_INTERVAL` | Behaviour |
|------|----------------|-----------|
| **Background** | `> 0` (e.g. `5000`) | Periodic fetch every N ms |
| **On-demand** | `0` / `off` / `false` | Fetch triggered by first user request when cache is empty |
### Cache Keys
| Key | Content | Source |
|-----|---------|--------|
| `poll:sab-queue` | `{ slots, status, speed, kbpersec }` | SABnzbd queue |
| `poll:sab-history` | `{ slots }` | SABnzbd history |
| `poll:sonarr-tags` | `[{ instance, data: [{id, label}] }]` | Sonarr tag API |
| `poll:sonarr-queue` | `{ records }` — includes embedded `series` objects | Sonarr queue (includeSeries) |
| `poll:sonarr-history` | `{ records }` — lightweight, no embedded objects | Sonarr history |
| `poll:radarr-queue` | `{ records }` — includes embedded `movie` objects | Radarr queue (includeMovie) |
| `poll:radarr-history` | `{ records }` — lightweight, no embedded objects | Radarr history |
| `poll:radarr-tags` | `[{id, label}]` | Radarr tag API |
| `poll:qbittorrent` | `[torrent, ...]` | qBittorrent all torrents |
### TTL Strategy
- **Polling enabled**: `POLL_INTERVAL × 3` — ensures data survives between polls even if one poll is slow
- **Polling disabled**: `30000` ms — stale after 30s, next request triggers a fresh fetch
### Active Client Tracking
Each dashboard request reports the client's refresh rate. The server tracks active clients in a `Map<username, { user, refreshRateMs, lastSeen }>`, pruning entries older than 30 seconds. This data powers the admin status panel's "effective refresh mode" display.
---
## 8. Download Matching Pipeline
The core logic in `dashboard.js` matches raw download client data to *arr service metadata to determine which user each download belongs to.
### Matching Strategy
For each download item (SABnzbd slot or qBittorrent torrent):
```
1. Try Sonarr QUEUE match (by title substring)
→ resolve series via seriesMap (embedded in queue record)
→ extract user tag → check tag matches requesting user
2. Try Radarr QUEUE match (by title substring)
→ resolve movie via moviesMap (embedded in queue record)
→ extract user tag → check tag matches requesting user
3. Try Sonarr HISTORY match (by title substring)
→ resolve series via seriesMap (from queue) using seriesId
→ extract user tag → check tag matches requesting user
4. Try Radarr HISTORY match (by title substring)
→ resolve movie via moviesMap (from queue) using movieId
→ extract user tag → check tag matches requesting user
```
### Title Matching
Matches are **bidirectional substring matches** (case-insensitive):
```javascript
rTitle.includes(downloadTitle) || downloadTitle.includes(rTitle)
```
### Download Object Structure
Each matched download produces an object with:
| Field | Type | Description |
|-------|------|-------------|
| `type` | `'series'` / `'movie'` / `'torrent'` | Media type |
| `title` | string | Raw download title |
| `coverArt` | string / null | Poster URL from *arr |
| `status` | string | Download status |
| `progress` | string | Percentage complete |
| `size` / `mb` / `mbmissing` | string / number | Size info |
| `speed` | string | Current download speed |
| `eta` | string | Estimated time remaining |
| `seriesName` / `movieName` | string | Friendly media title |
| `episodeInfo` / `movieInfo` | object | Full *arr queue/history record |
| `userTag` | string | Matched user tag |
| `importIssues` | string[] / null | Import warning/error messages |
| `downloadPath` | string / null | (Admin) Download client path |
| `targetPath` | string / null | (Admin) *arr target path |
| `arrLink` | string / null | (Admin) Link to *arr web UI |
---
## 9. API Reference
### `POST /api/auth/login`
Authenticate a user via Emby.
**Request Body:**
```json
{ "username": "string", "password": "string" }
```
**Response (200):**
```json
{
"success": true,
"user": { "id": "string", "name": "string", "isAdmin": true }
}
```
**Response (401):**
```json
{ "success": false, "error": "Invalid username or password" }
```
**Side Effect:** Sets `emby_user` httpOnly cookie (24h TTL).
---
### `GET /api/auth/me`
Check current session.
**Response:**
```json
{
"authenticated": true,
"user": { "id": "string", "name": "string", "isAdmin": false }
}
```
---
### `POST /api/auth/logout`
Clear session cookie.
---
### `GET /api/dashboard/user-downloads`
Fetch downloads for the authenticated user.
**Query Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| `showAll` | `"true"` | (Admin) Show all users' downloads |
| `refreshRate` | number (ms) | Client's current refresh rate for tracking |
**Response (200):**
```json
{
"user": "string",
"isAdmin": true,
"downloads": [ /* download objects */ ]
}
```
---
### `GET /api/dashboard/status`
Admin-only server status.
**Response (200):**
```json
{
"server": {
"uptimeSeconds": 3600,
"nodeVersion": "v18.19.0",
"memoryUsageMB": 45.2,
"heapUsedMB": 28.1,
"heapTotalMB": 35.0
},
"polling": {
"enabled": true,
"intervalMs": 5000,
"lastPoll": {
"totalMs": 1234,
"timestamp": "2026-05-16T00:00:00.000Z",
"tasks": [
{ "label": "SABnzbd Queue", "ms": 120 },
{ "label": "Sonarr Queue", "ms": 890 }
]
}
},
"cache": {
"entryCount": 9,
"totalSizeBytes": 51200,
"entries": [
{ "key": "poll:sab-queue", "sizeBytes": 1024, "itemCount": 3, "ttlRemainingMs": 12000, "expired": false }
]
},
"clients": [
{ "user": "Alice", "refreshRateMs": 5000, "lastSeen": 1715817600000 }
]
}
```
---
### `GET /api/dashboard/user-summary`
Admin-only per-user download counts (fetches live from APIs, not cached).
**Response (200):**
```json
[
{ "username": "Alice", "seriesCount": 12, "movieCount": 5 }
]
```
---
## 10. Frontend Architecture
The frontend is a **vanilla JavaScript SPA** with no build step. All logic resides in `app.js` (754 lines), styled by `style.css`, and structured by `index.html`.
### UI States
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Splash Screen│────▶│ Login Form │────▶│ Dashboard │
│ (on load) │ │ (if no │ │ (after auth) │
│ │ │ session) │ │ │
└──────────────┘ └──────────────┘ └──────────────┘
┌─────┴─────┐
│ Status │
│ Panel │
│ (admin) │
└───────────┘
```
### Key Frontend Functions
| Function | Purpose |
|----------|---------|
| `checkAuthentication()` | On load: check session → show dashboard or login |
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
| `fetchUserDownloads()` | GET `/user-downloads`, update state, re-render |
| `renderDownloads()` | Diff-based card rendering (create/update/remove) |
| `createDownloadCard()` | Build DOM for a single download card |
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
| `toggleStatusPanel()` | Show/hide admin status panel |
| `renderStatusPanel()` | Build status HTML (server, polling, cache, clients) |
| `startAutoRefresh()` | Start periodic `fetchUserDownloads` |
| `initThemeSwitcher()` | Light / Dark / Mono theme support |
### Themes
Three CSS themes via `data-theme` attribute on `<html>`:
- **Light** — Purple gradient header, white cards
- **Dark** — Dark surfaces, muted accents
- **Mono** — Monochrome, minimal colour
Theme selection persists in `localStorage`.
### Auto-Refresh
The dashboard polls the server at the user-selected interval (1s, 5s, 10s, or Off). The status panel auto-refreshes in sync. Client refresh rate is sent to the server on each request for active-client tracking.
---
## 11. Configuration
### Environment Variables
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `PORT` | No | `3001` | Server listen port |
| `EMBY_URL` | Yes | — | Emby/Jellyfin server URL |
| `EMBY_API_KEY` | Yes | — | Emby API key |
| `SONARR_INSTANCES` | Yes* | — | JSON array of Sonarr instances |
| `SONARR_URL` | Yes* | — | Legacy single Sonarr URL |
| `SONARR_API_KEY` | Yes* | — | Legacy single Sonarr API key |
| `RADARR_INSTANCES` | Yes* | — | JSON array of Radarr instances |
| `RADARR_URL` | Yes* | — | Legacy single Radarr URL |
| `RADARR_API_KEY` | Yes* | — | Legacy single Radarr API key |
| `SABNZBD_INSTANCES` | Yes* | — | JSON array of SABnzbd instances |
| `SABNZBD_URL` | Yes* | — | Legacy single SABnzbd URL |
| `SABNZBD_API_KEY` | Yes* | — | Legacy single SABnzbd API key |
| `QBITTORRENT_INSTANCES` | No | — | JSON array of qBittorrent instances |
| `POLL_INTERVAL` | No | `5000` | Poll interval in ms, or `off`/`0` to disable |
| `LOG_LEVEL` | No | `info` | `debug`, `info`, `warn`, `error`, `silent` |
\* Either `*_INSTANCES` (JSON array) or legacy `*_URL` + `*_API_KEY` format is required.
### Instance JSON Format
```json
[
{
"name": "main",
"url": "https://sonarr.example.com",
"apiKey": "your-api-key"
},
{
"name": "4k",
"url": "https://sonarr4k.example.com",
"apiKey": "your-4k-api-key"
}
]
```
qBittorrent instances use `username` and `password` instead of `apiKey`.
---
## 12. Deployment
### Docker
```dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server/ ./server/
COPY public/ ./public/
EXPOSE 3001
ENV NODE_ENV=production
CMD ["node", "server/index.js"]
```
### Docker Compose
```yaml
version: "3"
services:
sofarr:
image: docker.i3omb.com/sofarr:latest
container_name: sofarr
restart: unless-stopped
ports:
- "3001:3001"
environment:
- EMBY_URL=https://emby.example.com
- EMBY_API_KEY=your-emby-api-key
- SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
- RADARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
- SABNZBD_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
- QBITTORRENT_INSTANCES=[{"name":"main","url":"...","username":"...","password":"..."}]
- POLL_INTERVAL=5000
- LOG_LEVEL=info
```
---
## 13. UML Diagrams (PlantUML)
All diagrams below are in PlantUML syntax. Render with any PlantUML tool or VS Code extension.
### 13.1 Component Diagram
See [`diagrams/component.puml`](diagrams/component.puml)
### 13.2 Sequence Diagrams
- **Authentication**: [`diagrams/seq-auth.puml`](diagrams/seq-auth.puml)
- **Dashboard Request**: [`diagrams/seq-dashboard.puml`](diagrams/seq-dashboard.puml)
- **Polling Cycle**: [`diagrams/seq-polling.puml`](diagrams/seq-polling.puml)
### 13.3 Class / Entity Diagrams
- **Server Classes**: [`diagrams/class-server.puml`](diagrams/class-server.puml)
- **Data Model**: [`diagrams/class-data.puml`](diagrams/class-data.puml)
### 13.4 State Diagrams
- **Frontend UI States**: [`diagrams/state-ui.puml`](diagrams/state-ui.puml)
- **Poller States**: [`diagrams/state-poller.puml`](diagrams/state-poller.puml)
### 13.5 Activity Diagram
- **Download Matching**: [`diagrams/activity-matching.puml`](diagrams/activity-matching.puml)
+128
View File
@@ -0,0 +1,128 @@
@startuml activity-matching
!theme plain
title sofarr — Download Matching Activity Diagram
start
:Read cached data from MemoryCache;
note right
poll:sab-queue, poll:sab-history,
poll:sonarr-queue, poll:sonarr-history,
poll:radarr-queue, poll:radarr-history,
poll:sonarr-tags, poll:radarr-tags,
poll:qbittorrent
end note
:Build **seriesMap** from Sonarr queue records
(seriesId → embedded series object);
:Build **moviesMap** from Radarr queue records
(movieId → embedded movie object);
:Build **sonarrTagMap** (tagId → label)
Build **radarrTagMap** (tagId → label);
:Initialise **userDownloads** = [];
partition "Process SABnzbd Queue Slots" {
while (More queue slots?) is (yes)
:Get slot filename (nzbName);
:nzbNameLower = nzbName.toLowerCase();
if (Title matches Sonarr **queue** record?) then (yes)
:series = seriesMap.get(match.seriesId)\n|| match.series;
if (series exists?) then (yes)
:userTag = extractUserTag(series.tags, sonarrTagMap);
if (showAll OR tagMatchesUser?) then (yes)
:Build download object (type=series)
Add coverArt, status, progress, speed, eta
Add importIssues if any
Add admin fields (paths, arrLink);
:Push to **userDownloads**;
endif
endif
endif
if (Title matches Radarr **queue** record?) then (yes)
:movie = moviesMap.get(match.movieId)\n|| match.movie;
if (movie exists?) then (yes)
:userTag = extractUserTag(movie.tags, radarrTagMap);
if (showAll OR tagMatchesUser?) then (yes)
:Build download object (type=movie)
Add coverArt, status, progress, speed, eta
Add importIssues if any
Add admin fields (paths, arrLink);
:Push to **userDownloads**;
endif
endif
endif
endwhile (no)
}
partition "Process SABnzbd History Slots" {
while (More history slots?) is (yes)
:Get slot name (nzbName);
:nzbNameLower = nzbName.toLowerCase();
if (Title matches Sonarr **history** record?) then (yes)
:series = seriesMap.get(match.seriesId)\n|| match.series;
if (series found?) then (yes)
:Check user tag, build download\n(type=series, with completedAt);
:Push to **userDownloads** if tag matches;
endif
endif
if (Title matches Radarr **history** record?) then (yes)
:movie = moviesMap.get(match.movieId)\n|| match.movie;
if (movie found?) then (yes)
:Check user tag, build download\n(type=movie, with completedAt);
:Push to **userDownloads** if tag matches;
endif
endif
endwhile (no)
}
partition "Process qBittorrent Torrents" {
while (More torrents?) is (yes)
:Get torrent name;
:torrentNameLower = name.toLowerCase();
if (Matches Sonarr **queue**?) then (yes)
:Resolve series → check tag;
:mapTorrentToDownload() + enrich;
:Push if matches → **continue**;
elseif (Matches Radarr **queue**?) then (yes)
:Resolve movie → check tag;
:mapTorrentToDownload() + enrich;
:Push if matches → **continue**;
elseif (Matches Sonarr **history**?) then (yes)
:Resolve series via seriesMap;
:mapTorrentToDownload() + enrich;
:Push if matches → **continue**;
elseif (Matches Radarr **history**?) then (yes)
:Resolve movie via moviesMap;
:mapTorrentToDownload() + enrich;
:Push if matches → **continue**;
else (no match)
:Skip torrent (unmatched);
endif
endwhile (no)
}
:Return JSON response
{ user, isAdmin, downloads: userDownloads };
stop
legend right
**Title Matching Logic**
(bidirectional substring, case-insensitive):
""rTitle.includes(dlTitle) || dlTitle.includes(rTitle)""
**Tag Matching Logic**:
1. Exact: tag.toLowerCase() === username
2. Sanitised: sanitizeTagLabel(tag) === sanitizeTagLabel(username)
(handles Ombi-mangled email-style usernames)
end legend
@enduml
+221
View File
@@ -0,0 +1,221 @@
@startuml class-data
!theme plain
title sofarr — Data Model Diagram
skinparam classAttributeIconSize 0
package "External API Responses" {
class "SABnzbd Queue Slot" as sabq {
+ filename : string
+ nzbname : string
+ percentage : string
+ mb : string
+ mbmissing : string
+ size : string
+ timeleft : string
+ status : string
+ storage : string
}
class "SABnzbd History Slot" as sabh {
+ name : string
+ nzb_name : string
+ nzbname : string
+ status : string
+ size : string
+ completed_time : string
+ storage : string
}
class "Sonarr Queue Record" as sqr {
+ id : number
+ seriesId : number
+ series : SonarrSeries
+ title : string
+ sourceTitle : string
+ trackedDownloadStatus : string
+ trackedDownloadState : string
+ statusMessages : StatusMessage[]
+ errorMessage : string
}
class "Sonarr History Record" as shr {
+ id : number
+ seriesId : number
+ title : string
+ sourceTitle : string
+ eventType : string
}
class "SonarrSeries" as ss {
+ id : number
+ title : string
+ titleSlug : string
+ path : string
+ tags : number[]
+ images : Image[]
+ _instanceUrl : string
}
class "Radarr Queue Record" as rqr {
+ id : number
+ movieId : number
+ movie : RadarrMovie
+ title : string
+ sourceTitle : string
+ trackedDownloadStatus : string
+ trackedDownloadState : string
+ statusMessages : StatusMessage[]
+ errorMessage : string
}
class "Radarr History Record" as rhr {
+ id : number
+ movieId : number
+ title : string
+ sourceTitle : string
+ eventType : string
}
class "RadarrMovie" as rm {
+ id : number
+ title : string
+ titleSlug : string
+ path : string
+ tags : number[]
+ images : Image[]
+ _instanceUrl : string
}
class "Tag" as tag {
+ id : number
+ label : string
}
class "Image" as img {
+ coverType : string
+ remoteUrl : string
+ url : string
}
class "StatusMessage" as sm {
+ title : string
+ messages : string[]
}
class "qBittorrent Torrent" as qbt {
+ name : string
+ hash : string
+ size : number
+ completed : number
+ progress : number (0-1)
+ state : string
+ dlspeed : number
+ eta : number
+ num_seeds : number
+ num_leechs : number
+ availability : number
+ category : string
+ tags : string
+ save_path : string
+ content_path : string
+ instanceId : string
+ instanceName : string
}
class "Emby User" as eu {
+ Id : string
+ Name : string
+ Policy : { IsAdministrator: boolean }
}
sqr *-- ss : embedded\n(includeSeries)
rqr *-- rm : embedded\n(includeMovie)
sqr *-- sm
rqr *-- sm
ss *-- img
rm *-- img
}
package "sofarr Internal Models" {
class "Download Object" as dl {
+ type : 'series' | 'movie' | 'torrent'
+ title : string
+ coverArt : string | null
+ status : string
+ progress : string
+ mb : string
+ mbmissing : string
+ size : string
+ speed : string
+ eta : string
+ seriesName : string | null
+ movieName : string | null
+ episodeInfo : object | null
+ movieInfo : object | null
+ userTag : string
+ importIssues : string[] | null
+ downloadPath : string | null
+ targetPath : string | null
+ arrLink : string | null
+ qbittorrent : boolean
+ seeds : number
+ peers : number
+ availability : string
+ rawSize : number
+ rawSpeed : number
+ rawEta : number
+ hash : string
+ category : string
+ completedAt : string
}
class "API Response\n/user-downloads" as apir {
+ user : string
+ isAdmin : boolean
+ downloads : Download[]
}
class "Status Response\n/status" as statr {
+ server : ServerInfo
+ polling : PollingInfo
+ cache : CacheStats
+ clients : ClientInfo[]
}
class "ServerInfo" as si {
+ uptimeSeconds : number
+ nodeVersion : string
+ memoryUsageMB : number
+ heapUsedMB : number
+ heapTotalMB : number
}
class "PollingInfo" as pi {
+ enabled : boolean
+ intervalMs : number
+ lastPoll : PollTimings
}
class "Session Cookie\nemby_user" as cookie {
+ id : string
+ name : string
+ isAdmin : boolean
+ token : string
}
apir *-- dl
statr *-- si
statr *-- pi
}
' Data flow connections
sabq ..> dl : matched &\ntransformed
sabh ..> dl : matched &\ntransformed
qbt ..> dl : mapTorrentToDownload()
ss ..> dl : coverArt, seriesName,\npath, tags
rm ..> dl : coverArt, movieName,\npath, tags
tag ..> dl : userTag resolution
eu ..> cookie : login creates
@enduml
+197
View File
@@ -0,0 +1,197 @@
@startuml class-server
!theme plain
title sofarr — Server Class / Module Diagram
package "server/index.js" as entry {
class "EntryPoint" as ep <<module>> {
- LOG_LEVELS : Object
- currentLevel : number
- logFile : WriteStream
+ shouldLog(level) : boolean
--
Configures Express app,
mounts routes, starts poller
}
}
package "server/routes" {
class "auth.js" as auth <<router>> {
+ POST /login
+ GET /me
+ POST /logout
--
Authenticates via Emby API
Sets/reads httpOnly cookie
}
class "dashboard.js" as dashboard <<router>> {
- activeClients : Map<string, ClientInfo>
- CLIENT_STALE_MS : 30000
--
+ GET /user-downloads
+ GET /user-summary
+ GET /status
--
- getCoverArt(item) : string|null
- extractUserTag(tags, tagMap) : string|null
- sanitizeTagLabel(input) : string
- tagMatchesUser(tag, username) : boolean
- getImportIssues(record) : string[]|null
- getSonarrLink(series) : string|null
- getRadarrLink(movie) : string|null
- getActiveClients() : ClientInfo[]
}
class "emby.js" as emby_r <<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/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 "ClientInfo" as ci <<value>> {
+ user : string
+ refreshRateMs : number
+ lastSeen : number (timestamp)
}
}
' Relationships
ep --> auth
ep --> dashboard
ep --> emby_r
ep --> sab_r
ep --> sonarr_r
ep --> radarr_r
ep --> poller : startPoller()
dashboard --> cache : read/write
dashboard --> poller : pollAllServices()
dashboard --> qbt_mod : mapTorrentToDownload()
dashboard --> config
poller --> cache : set poll:* keys
poller --> config : get instances
poller --> qbt_mod : getTorrents()
qbt_mod --> config : getQbittorrentInstances()
qbt_mod *-- qbt : creates
qbt --> logger
cache *-- ce : stores
cache ..> cs : returns from getStats()
poller ..> pt : stores/returns
dashboard *-- ci : stores in activeClients
config ..> inst : returns
@enduml
+94
View File
@@ -0,0 +1,94 @@
@startuml component
!theme plain
title sofarr — Component Diagram
skinparam componentStyle rectangle
skinparam packageStyle frame
package "Browser" as browser {
[index.html] as html
[app.js] as appjs
[style.css] as css
html ..> appjs : loads
html ..> css : loads
}
package "Express Server" as server {
package "Middleware" {
[CORS] as cors
[cookie-parser] as cp
[express.json] as ej
[express.static] as es
}
package "Routes" as routes {
[auth.js\n/api/auth] as auth
[dashboard.js\n/api/dashboard] as dashboard
[emby.js\n/api/emby] as emby_route
[sabnzbd.js\n/api/sabnzbd] as sab_route
[sonarr.js\n/api/sonarr] as sonarr_route
[radarr.js\n/api/radarr] as radarr_route
}
package "Utilities" as utils {
[poller.js] as poller
[cache.js\nMemoryCache] as cache
[config.js] as config
[qbittorrent.js\nQBittorrentClient] as qbt
[logger.js] as logger
}
[index.js\nEntry Point] as entry
entry --> cors
entry --> cp
entry --> ej
entry --> es
entry --> auth
entry --> dashboard
entry --> emby_route
entry --> sab_route
entry --> sonarr_route
entry --> radarr_route
entry --> poller : startPoller()
dashboard --> cache : read poll:* keys
dashboard --> poller : pollAllServices()\n(on-demand mode)
dashboard --> config : getSonarrInstances()\ngetRadarrInstances()
dashboard --> qbt : mapTorrentToDownload()
poller --> cache : set poll:* keys
poller --> config : get all instances
poller --> qbt : getTorrents()
poller --> logger
qbt --> config : getQbittorrentInstances()
qbt --> logger
}
cloud "External Services" as external {
[Emby / Jellyfin] as emby
[SABnzbd] as sab
[Sonarr] as sonarr
[Radarr] as radarr
[qBittorrent] as qbit
}
auth --> emby : authenticate\nuser profile
dashboard ..> emby : /user-summary\n(live fetch)
emby_route --> emby
sab_route --> sab
sonarr_route --> sonarr
radarr_route --> radarr
poller --> sab : queue + history
poller --> sonarr : tags + queue + history
poller --> radarr : tags + queue + history
qbt --> qbit : login + torrents/info
appjs --> auth : POST /login\nGET /me
appjs --> dashboard : GET /user-downloads\nGET /status
es --> html : serve static
@enduml
+67
View File
@@ -0,0 +1,67 @@
@startuml seq-auth
!theme plain
title sofarr — Authentication Sequence
actor User as user
participant "Browser\n(app.js)" as browser
participant "Express\n/api/auth" as auth
participant "Emby\nServer" as emby
== Page Load ==
user -> browser : Navigate to sofarr
activate browser
browser -> auth : GET /api/auth/me
activate auth
auth -> auth : Read emby_user cookie
alt Cookie exists and valid
auth --> browser : { authenticated: true, user: { name, isAdmin } }
browser -> browser : showDashboard()
browser -> browser : fetchUserDownloads(true)
browser -> browser : startAutoRefresh()
browser -> browser : dismissSplash()
else No cookie
auth --> browser : { authenticated: false }
browser -> browser : dismissSplash()
browser -> browser : showLogin()
end
deactivate auth
== Login ==
user -> browser : Enter username + password
browser -> auth : POST /api/auth/login\n{ username, password }
activate auth
auth -> emby : POST /Users/authenticatebyname\n{ Username, Pw }
activate emby
alt Valid credentials
emby --> auth : { User: { Id, ... }, AccessToken }
auth -> emby : GET /Users/{userId}
emby --> auth : { Name, Policy: { IsAdministrator } }
deactivate emby
auth -> auth : Set httpOnly cookie\nemby_user = { id, name, isAdmin, token }\n(24h TTL)
auth --> browser : { success: true, user: { name, isAdmin } }
browser -> browser : fadeOutLogin()
browser -> browser : showSplash()
browser -> browser : showDashboard()
browser -> browser : fetchUserDownloads(true)
browser -> browser : startAutoRefresh()
browser -> browser : dismissSplash()
else Invalid credentials
emby --> auth : 401 Error
deactivate emby
auth --> browser : { success: false, error: "Invalid..." }
browser -> browser : showLoginError()
end
deactivate auth
== Logout ==
user -> browser : Click Logout
browser -> browser : stopAutoRefresh()
browser -> auth : POST /api/auth/logout
activate auth
auth -> auth : Clear emby_user cookie
auth --> browser : { success: true }
deactivate auth
browser -> browser : showLogin()
deactivate browser
@enduml
+85
View File
@@ -0,0 +1,85 @@
@startuml seq-dashboard
!theme plain
title sofarr — Dashboard Request Sequence
actor User as user
participant "Browser\n(app.js)" as browser
participant "Express\n/api/dashboard" as dashboard
participant "MemoryCache" as cache
participant "Poller" as poller
participant "External\nServices" as ext
== Periodic Refresh (or Initial Load) ==
user -> browser : (auto-refresh fires)
activate browser
browser -> dashboard : GET /api/dashboard/user-downloads\n?refreshRate=5000&showAll=false
activate dashboard
dashboard -> dashboard : Parse emby_user cookie\nExtract username, isAdmin
dashboard -> dashboard : Track client refresh rate\nin activeClients Map
alt Polling disabled AND cache empty
dashboard -> poller : pollAllServices()
activate poller
poller -> ext : Parallel API calls\n(SAB, Sonarr, Radarr, qBit)
ext --> poller : Raw data
poller -> cache : set poll:* keys\n(TTL = 30s)
deactivate poller
end
dashboard -> cache : get('poll:sab-queue')
cache --> dashboard : { slots, status, speed }
dashboard -> cache : get('poll:sab-history')
cache --> dashboard : { slots }
dashboard -> cache : get('poll:sonarr-tags')
cache --> dashboard : [{ instance, data }]
dashboard -> cache : get('poll:sonarr-queue')
cache --> dashboard : { records } (with embedded series)
dashboard -> cache : get('poll:sonarr-history')
cache --> dashboard : { records }
dashboard -> cache : get('poll:radarr-queue')
cache --> dashboard : { records } (with embedded movie)
dashboard -> cache : get('poll:radarr-history')
cache --> dashboard : { records }
dashboard -> cache : get('poll:radarr-tags')
cache --> dashboard : [{id, label}]
dashboard -> cache : get('poll:qbittorrent')
cache --> dashboard : [torrent, ...]
dashboard -> dashboard : Build seriesMap from\nSonarr queue records
dashboard -> dashboard : Build moviesMap from\nRadarr queue records
dashboard -> dashboard : Build tag maps\n(id → label)
group SABnzbd Queue Matching
loop each queue slot
dashboard -> dashboard : Match title vs Sonarr queue
dashboard -> dashboard : Match title vs Radarr queue
dashboard -> dashboard : Resolve series/movie\n→ extract user tag\n→ filter by username
end
end
group SABnzbd History Matching
loop each history slot
dashboard -> dashboard : Match title vs Sonarr history
dashboard -> dashboard : Match title vs Radarr history
dashboard -> dashboard : Resolve via seriesMap/moviesMap\n→ extract user tag\n→ filter
end
end
group qBittorrent Matching
loop each torrent
dashboard -> dashboard : 1. Match vs Sonarr queue
dashboard -> dashboard : 2. Match vs Radarr queue
dashboard -> dashboard : 3. Match vs Sonarr history
dashboard -> dashboard : 4. Match vs Radarr history
dashboard -> dashboard : mapTorrentToDownload()\n→ enrich with series/movie info
end
end
dashboard --> browser : { user, isAdmin,\ndownloads: [...] }
deactivate dashboard
browser -> browser : renderDownloads()\n(diff-based update)
deactivate browser
@enduml
+89
View File
@@ -0,0 +1,89 @@
@startuml seq-polling
!theme plain
title sofarr — Background Polling Cycle
participant "index.js\n(startup)" as entry
participant "Poller" as poller
participant "Config" as config
participant "SABnzbd\n(per instance)" as sab
participant "Sonarr\n(per instance)" as sonarr
participant "Radarr\n(per instance)" as radarr
participant "qBittorrent\nClient" as qbt
participant "MemoryCache" as cache
== Startup ==
entry -> poller : startPoller()
activate poller
alt POLL_INTERVAL > 0
poller -> poller : pollAllServices() (immediate)
poller -> poller : setInterval(pollAllServices,\nPOLL_INTERVAL)
else POLL_INTERVAL = 0
poller --> entry : "Polling disabled, on-demand mode"
end
== Poll Cycle ==
poller -> poller : Check: polling flag?\n(skip if concurrent)
poller -> poller : polling = true
poller -> poller : start = Date.now()
poller -> config : getSABnzbdInstances()
config --> poller : [{ id, url, apiKey }]
poller -> config : getSonarrInstances()
config --> poller : [{ id, url, apiKey }]
poller -> config : getRadarrInstances()
config --> poller : [{ id, url, apiKey }]
note over poller : All fetches run in\nparallel via Promise.all,\neach wrapped in timed()
par SABnzbd Queue
poller -> sab : GET /api?mode=queue
sab --> poller : { queue: { slots, status, speed } }
and SABnzbd History
poller -> sab : GET /api?mode=history&limit=10
sab --> poller : { history: { slots } }
and Sonarr Tags
poller -> sonarr : GET /api/v3/tag
sonarr --> poller : [{ id, label }]
and Sonarr Queue
poller -> sonarr : GET /api/v3/queue\n?includeSeries=true
sonarr --> poller : { records: [{ seriesId, series, ... }] }
and Sonarr History
poller -> sonarr : GET /api/v3/history\n?pageSize=10
sonarr --> poller : { records: [{ seriesId, ... }] }
and Radarr Queue
poller -> radarr : GET /api/v3/queue\n?includeMovie=true
radarr --> poller : { records: [{ movieId, movie, ... }] }
and Radarr History
poller -> radarr : GET /api/v3/history\n?pageSize=10
radarr --> poller : { records: [{ movieId, ... }] }
and Radarr Tags
poller -> radarr : GET /api/v3/tag
radarr --> poller : [{ id, label }]
and qBittorrent
poller -> qbt : getTorrents()
qbt --> poller : [{ name, progress, ... }]
end
poller -> poller : Record per-task timings\nlastPollTimings = { totalMs,\ntimestamp, tasks: [{label, ms}] }
poller -> poller : cacheTTL = POLL_INTERVAL × 3
poller -> cache : set('poll:sab-queue', ..., cacheTTL)
poller -> cache : set('poll:sab-history', ..., cacheTTL)
poller -> cache : set('poll:sonarr-tags', ..., cacheTTL)
note over poller : Tag queue records with\n_instanceUrl on embedded\nseries/movie objects
poller -> cache : set('poll:sonarr-queue', ..., cacheTTL)
poller -> cache : set('poll:sonarr-history', ..., cacheTTL)
poller -> cache : set('poll:radarr-queue', ..., cacheTTL)
poller -> cache : set('poll:radarr-history', ..., cacheTTL)
poller -> cache : set('poll:radarr-tags', ..., cacheTTL)
poller -> cache : set('poll:qbittorrent', ..., cacheTTL)
poller -> poller : polling = false\nlog elapsed time
deactivate poller
@enduml
+65
View File
@@ -0,0 +1,65 @@
@startuml state-poller
!theme plain
title sofarr — Poller State Diagram
[*] --> CheckConfig : startPoller()
state CheckConfig <<choice>>
CheckConfig --> Disabled : POLL_INTERVAL = 0\nor 'off' / 'false'
CheckConfig --> Idle : POLL_INTERVAL > 0
state Disabled {
state "On-demand mode\nNo background timer" as od
od : Data fetched only when\na dashboard request\nfinds empty cache
}
Disabled --> Polling : pollAllServices()\n(triggered by dashboard request)
Polling --> Disabled : Poll complete\n(return to on-demand)
state Idle {
state "Waiting for\nnext interval" as waiting
}
Idle --> Polling : setInterval fires\nor immediate first poll
state Polling {
state "polling = true" as lock
state "Fetching all services\n(Promise.all)" as fetching
state "Storing results\nin cache" as storing
state "Recording timings" as timing
[*] --> lock
lock --> fetching
fetching --> storing : All promises resolved
fetching --> ErrorState : Any individual service\nerror (caught per-service)
storing --> timing
timing --> [*] : polling = false
}
state ErrorState as "Handle Error" {
state "Log error\npolling = false" as err
}
ErrorState --> Idle : Next interval
Polling --> Idle : Poll complete\n(back to waiting)
state "Concurrent Poll\nAttempt" as skip {
state "polling === true\n→ skip" as sk
}
Idle --> skip : Interval fires while\nprevious still running
skip --> Idle : Log "still running,\nskipping"
note right of Polling
**Cache TTL**: POLL_INTERVAL × 3
Ensures data survives between polls
even if one cycle is slow.
end note
note right of Disabled
**Cache TTL**: 30000ms (30s)
After expiry, next dashboard
request triggers a fresh poll.
end note
@enduml
+79
View File
@@ -0,0 +1,79 @@
@startuml state-ui
!theme plain
title sofarr — Frontend UI State Diagram
[*] --> SplashScreen : Page load
state SplashScreen {
state "Showing splash\n(min 1.2s)" as showing
}
SplashScreen --> CheckAuth : checkAuthentication()
state CheckAuth <<choice>>
CheckAuth --> LoginForm : No session cookie
CheckAuth --> Dashboard : Valid session
state LoginForm {
state "Idle" as lf_idle
state "Submitting" as lf_submit
state "Error" as lf_error
lf_idle --> lf_submit : Submit form
lf_submit --> lf_error : Auth failed
lf_error --> lf_submit : Re-submit
lf_submit --> FadeOutLogin : Auth success
}
state FadeOutLogin {
state "CSS transition\n(opacity → 0)" as fade
}
FadeOutLogin --> SplashScreen2 : Show splash\nwhile loading
state SplashScreen2 as "Splash (loading data)" {
state "fetchUserDownloads()" as fetching
}
SplashScreen2 --> Dashboard : Data loaded\ndismissSplash()
state Dashboard {
state "Rendering Cards" as rendering
state "Auto Refreshing" as refreshing
state "Status Panel Open" as status_open
state "Status Panel Closed" as status_closed
[*] --> rendering
rendering --> refreshing : startAutoRefresh()
refreshing --> rendering : fetchUserDownloads()\n→ renderDownloads()
rendering --> rendering : Theme change
status_closed --> status_open : Click "Status" btn\n(admin only)
status_open --> status_closed : Click close (×)
status_open --> status_open : Auto-refresh\nrenderStatusPanel()
[*] --> status_closed
state "Refresh Rate" as rr {
state "1s" as r1
state "5s (default)" as r5
state "10s" as r10
state "Off" as roff
r5 --> r1 : User selects
r5 --> r10
r5 --> roff
r1 --> r5
r1 --> r10
r1 --> roff
r10 --> r1
r10 --> r5
r10 --> roff
roff --> r1
roff --> r5
roff --> r10
}
}
Dashboard --> LoginForm : Logout\n(clear cookie,\nstopAutoRefresh)
@enduml
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "0.1.1", "version": "0.1.4",
"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": {
+255 -5
View File
@@ -2,6 +2,9 @@ let currentUser = null;
let downloads = []; let downloads = [];
let refreshInterval = null; let refreshInterval = null;
let currentRefreshRate = 5000; // default 5 seconds let currentRefreshRate = 5000; // default 5 seconds
let isAdmin = false;
let showAll = false;
const SPLASH_MIN_MS = 1200; // minimum splash display time
// Apply saved theme immediately (before DOMContentLoaded to avoid flash) // Apply saved theme immediately (before DOMContentLoaded to avoid flash)
(function() { (function() {
@@ -17,6 +20,8 @@ document.addEventListener('DOMContentLoaded', () => {
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);
document.getElementById('refresh-rate').addEventListener('change', handleRefreshRateChange); document.getElementById('refresh-rate').addEventListener('change', handleRefreshRateChange);
document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle);
document.getElementById('status-btn').addEventListener('click', toggleStatusPanel);
}); });
function initThemeSwitcher() { function initThemeSwitcher() {
@@ -46,6 +51,19 @@ function handleRefreshRateChange(e) {
const rate = parseInt(e.target.value); const rate = parseInt(e.target.value);
currentRefreshRate = rate; currentRefreshRate = rate;
startAutoRefresh(); startAutoRefresh();
// Restart status panel refresh if it's open
const statusPanel = document.getElementById('status-panel');
if (statusPanel && statusPanel.style.display !== 'none') {
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
if (currentRefreshRate > 0) {
statusRefreshHandle = setInterval(refreshStatusPanel, currentRefreshRate);
}
}
}
function handleShowAllToggle(e) {
showAll = e.target.checked;
fetchUserDownloads(true);
} }
function stopAutoRefresh() { function stopAutoRefresh() {
@@ -55,21 +73,60 @@ function stopAutoRefresh() {
} }
} }
function fadeOutLogin() {
return new Promise(resolve => {
const login = document.getElementById('login-container');
login.classList.add('fade-out');
login.addEventListener('transitionend', () => {
login.style.display = 'none';
login.classList.remove('fade-out');
resolve();
}, { once: true });
});
}
function showSplash() {
const splash = document.getElementById('splash-screen');
splash.style.display = 'flex';
splash.style.opacity = '1';
splash.classList.remove('fade-out');
}
function dismissSplash(startTime) {
return new Promise(resolve => {
const elapsed = Date.now() - (startTime || 0);
const remaining = Math.max(0, SPLASH_MIN_MS - elapsed);
setTimeout(() => {
const splash = document.getElementById('splash-screen');
splash.classList.add('fade-out');
splash.addEventListener('transitionend', () => {
splash.style.display = 'none';
resolve();
}, { once: true });
}, remaining);
});
}
async function checkAuthentication() { async function checkAuthentication() {
const splashStart = Date.now();
try { try {
const response = await fetch('/api/auth/me'); const response = await fetch('/api/auth/me');
const data = await response.json(); const data = await response.json();
if (data.authenticated) { if (data.authenticated) {
currentUser = data.user; currentUser = data.user;
isAdmin = !!data.user.isAdmin;
showDashboard(); showDashboard();
fetchUserDownloads(true); await fetchUserDownloads(true);
startAutoRefresh(); startAutoRefresh();
await dismissSplash(splashStart);
} else { } else {
await dismissSplash(splashStart);
showLogin(); showLogin();
} }
} catch (err) { } catch (err) {
console.error('Authentication check failed:', err); console.error('Authentication check failed:', err);
await dismissSplash(splashStart);
showLogin(); showLogin();
} }
} }
@@ -93,9 +150,15 @@ async function handleLogin(e) {
if (data.success) { if (data.success) {
currentUser = data.user; currentUser = data.user;
isAdmin = !!data.user.isAdmin;
// Fade out login, then show splash while loading data
await fadeOutLogin();
showSplash();
showDashboard(); showDashboard();
fetchUserDownloads(true); const splashStart = Date.now();
await fetchUserDownloads(true);
startAutoRefresh(); startAutoRefresh();
await dismissSplash(splashStart);
} else { } else {
showLoginError(data.error || 'Login failed'); showLoginError(data.error || 'Login failed');
} }
@@ -129,6 +192,7 @@ function showDashboard() {
document.getElementById('login-container').style.display = 'none'; document.getElementById('login-container').style.display = 'none';
document.getElementById('dashboard-container').style.display = 'block'; document.getElementById('dashboard-container').style.display = 'block';
document.getElementById('currentUser').textContent = currentUser.name || '-'; document.getElementById('currentUser').textContent = currentUser.name || '-';
document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none';
} }
function showLoginError(message) { function showLoginError(message) {
@@ -149,10 +213,15 @@ async function fetchUserDownloads(isInitialLoad = false) {
hideError(); hideError();
try { try {
const response = await fetch('/api/dashboard/user-downloads'); const params = new URLSearchParams();
if (showAll) params.set('showAll', 'true');
params.set('refreshRate', currentRefreshRate);
const url = '/api/dashboard/user-downloads?' + params.toString();
const response = await fetch(url);
const data = await response.json(); const data = await response.json();
currentUser = data.user; currentUser = data.user;
isAdmin = !!data.isAdmin;
downloads = data.downloads; downloads = data.downloads;
// Debug: log first download to see what fields are present // Debug: log first download to see what fields are present
@@ -327,6 +396,14 @@ function createDownloadCard(download) {
header.appendChild(type); header.appendChild(type);
header.appendChild(status); header.appendChild(status);
if (download.importIssues && download.importIssues.length > 0) {
const issueBadge = document.createElement('span');
issueBadge.className = 'import-issue-badge';
issueBadge.textContent = 'Import Pending';
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
header.appendChild(issueBadge);
}
const title = document.createElement('h3'); const title = document.createElement('h3');
title.className = 'download-title'; title.className = 'download-title';
@@ -338,16 +415,31 @@ function createDownloadCard(download) {
if (download.seriesName) { if (download.seriesName) {
const series = document.createElement('p'); const series = document.createElement('p');
series.className = 'download-series'; series.className = 'download-series';
series.textContent = `Series: ${download.seriesName}`; if (isAdmin && download.arrLink) {
series.innerHTML = 'Series: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.seriesName) + '</a>';
} else {
series.textContent = `Series: ${download.seriesName}`;
}
infoDiv.appendChild(series); infoDiv.appendChild(series);
} }
if (download.movieName) { if (download.movieName) {
const movie = document.createElement('p'); const movie = document.createElement('p');
movie.className = 'download-movie'; movie.className = 'download-movie';
movie.textContent = `Movie: ${download.movieName}`; if (isAdmin && download.arrLink) {
movie.innerHTML = 'Movie: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.movieName) + '</a>';
} else {
movie.textContent = `Movie: ${download.movieName}`;
}
infoDiv.appendChild(movie); infoDiv.appendChild(movie);
} }
if (showAll && download.userTag) {
const userBadge = document.createElement('span');
userBadge.className = 'download-user-badge';
userBadge.textContent = download.userTag;
header.appendChild(userBadge);
}
const details = document.createElement('div'); const details = document.createElement('div');
details.className = 'download-details'; details.className = 'download-details';
@@ -438,6 +530,24 @@ function createDownloadCard(download) {
const completed = createDetailItem('Completed', formatDate(download.completedAt)); const completed = createDetailItem('Completed', formatDate(download.completedAt));
details.appendChild(completed); details.appendChild(completed);
} }
if (isAdmin && (download.downloadPath || download.targetPath)) {
const pathsDiv = document.createElement('div');
pathsDiv.className = 'download-paths';
if (download.downloadPath) {
const dlPath = document.createElement('div');
dlPath.className = 'path-item';
dlPath.innerHTML = '<span class="path-label">Download:</span> <span class="path-value">' + escapeHtml(download.downloadPath) + '</span>';
pathsDiv.appendChild(dlPath);
}
if (download.targetPath) {
const tgtPath = document.createElement('div');
tgtPath.className = 'path-item';
tgtPath.innerHTML = '<span class="path-label">Target:</span> <span class="path-value">' + escapeHtml(download.targetPath) + '</span>';
pathsDiv.appendChild(tgtPath);
}
details.appendChild(pathsDiv);
}
infoDiv.appendChild(details); infoDiv.appendChild(details);
card.appendChild(infoDiv); card.appendChild(infoDiv);
@@ -464,6 +574,146 @@ function createDetailItem(label, value) {
return item; return item;
} }
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
let statusRefreshHandle = null;
async function toggleStatusPanel() {
const panel = document.getElementById('status-panel');
if (panel.style.display !== 'none') {
panel.style.display = 'none';
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
return;
}
panel.style.display = 'block';
await refreshStatusPanel();
// Auto-refresh in sync with dashboard refresh rate
if (statusRefreshHandle) clearInterval(statusRefreshHandle);
if (currentRefreshRate > 0) {
statusRefreshHandle = setInterval(refreshStatusPanel, currentRefreshRate);
}
}
function closeStatusPanel() {
document.getElementById('status-panel').style.display = 'none';
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; }
}
async function refreshStatusPanel() {
const panel = document.getElementById('status-panel');
if (!panel || panel.style.display === 'none') return;
try {
const res = await fetch('/api/dashboard/status');
if (!res.ok) throw new Error('Failed to fetch status');
const data = await res.json();
renderStatusPanel(data, panel);
} catch (err) {
// Don't overwrite panel on transient error during auto-refresh
if (!panel.innerHTML || panel.innerHTML.includes('status-loading')) {
panel.innerHTML = '<p class="status-error">Failed to load status.</p>';
}
}
}
function renderStatusPanel(data, panel) {
const s = data.server;
const hrs = Math.floor(s.uptimeSeconds / 3600);
const mins = Math.floor((s.uptimeSeconds % 3600) / 60);
const secs = s.uptimeSeconds % 60;
const uptime = `${hrs}h ${mins}m ${secs}s`;
const totalKB = (data.cache.totalSizeBytes / 1024).toFixed(1);
let html = `
<div class="status-header">
<h3>Server Status</h3>
<button class="status-close" onclick="closeStatusPanel()">&times;</button>
</div>
<div class="status-grid">
<div class="status-card">
<div class="status-card-title">Server</div>
<div class="status-row"><span>Uptime</span><span>${uptime}</span></div>
<div class="status-row"><span>Node</span><span>${escapeHtml(s.nodeVersion)}</span></div>
<div class="status-row"><span>Memory (RSS)</span><span>${s.memoryUsageMB} MB</span></div>
<div class="status-row"><span>Heap</span><span>${s.heapUsedMB} / ${s.heapTotalMB} MB</span></div>
</div>
<div class="status-card">
<div class="status-card-title">Data Refresh</div>`;
const pollIntervalMs = data.polling.intervalMs;
const clients = data.clients || [];
const activeRefreshers = clients.filter(c => c.refreshRateMs > 0);
const fastestClient = activeRefreshers.length > 0
? activeRefreshers.reduce((min, c) => c.refreshRateMs < min.refreshRateMs ? c : min)
: null;
const hasForegroundClient = fastestClient && data.polling.enabled && fastestClient.refreshRateMs < pollIntervalMs;
if (data.polling.enabled) {
html += `<div class="status-row"><span>Background poll</span><span>${pollIntervalMs / 1000}s</span></div>`;
} else {
html += `<div class="status-row"><span>Background poll</span><span>Disabled</span></div>`;
}
if (hasForegroundClient) {
html += `<div class="status-row"><span>Effective mode</span><span class="status-fg-badge">Foreground ${fastestClient.refreshRateMs / 1000}s</span></div>`;
} else if (activeRefreshers.length > 0) {
html += `<div class="status-row"><span>Effective mode</span><span>${data.polling.enabled ? 'Background' : 'On-demand'}</span></div>`;
} else {
html += `<div class="status-row"><span>Effective mode</span><span>Idle (no active clients)</span></div>`;
}
html += `<div class="status-row"><span>Active clients</span><span>${clients.length}</span></div>`;
for (const c of clients) {
const rate = c.refreshRateMs > 0 ? (c.refreshRateMs / 1000) + 's' : 'Off';
const age = Math.round((Date.now() - c.lastSeen) / 1000);
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>${rate} (${age}s ago)</span></div>`;
}
html += `</div>`;
// Poll timings card
const lp = data.polling.lastPoll;
if (lp) {
const pollAge = Math.round((Date.now() - new Date(lp.timestamp).getTime()) / 1000);
html += `
<div class="status-card status-card-wide">
<div class="status-card-title">Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)</div>
<div class="status-timings">`;
for (const t of lp.tasks) {
const barWidth = lp.totalMs > 0 ? Math.max(2, (t.ms / lp.totalMs) * 100) : 0;
html += `
<div class="timing-row">
<span class="timing-label">${escapeHtml(t.label)}</span>
<div class="timing-bar-bg"><div class="timing-bar" style="width:${barWidth.toFixed(1)}%"></div></div>
<span class="timing-value">${t.ms}ms</span>
</div>`;
}
html += `</div></div>`;
}
// Cache table
html += `
<div class="status-card status-card-wide">
<div class="status-card-title">Cache (${data.cache.entryCount} entries, ${totalKB} KB)</div>
<table class="status-table">
<thead><tr><th>Key</th><th>Items</th><th>Size</th><th>TTL</th></tr></thead>
<tbody>`;
for (const e of data.cache.entries) {
const sizeStr = e.sizeBytes > 1024 ? (e.sizeBytes / 1024).toFixed(1) + ' KB' : e.sizeBytes + ' B';
const ttlStr = e.expired ? '<span class="status-expired">expired</span>' : (e.ttlRemainingMs / 1000).toFixed(0) + 's';
const items = e.itemCount !== null ? e.itemCount : '—';
html += `<tr><td><code>${escapeHtml(e.key)}</code></td><td>${items}</td><td>${sizeStr}</td><td>${ttlStr}</td></tr>`;
}
html += `</tbody></table></div></div>`;
panel.innerHTML = html;
}
function formatSize(size) { function formatSize(size) {
if (!size) return 'N/A'; if (!size) return 'N/A';
// If already a formatted string (e.g., "21.5 GB"), return as-is // If already a formatted string (e.g., "21.5 GB"), return as-is
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+16 -1
View File
@@ -7,11 +7,17 @@
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
<!-- Splash Screen -->
<div id="splash-screen" class="splash-screen">
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="splash-logo">
</div>
<div class="app"> <div class="app">
<!-- Login Form --> <!-- Login Form -->
<div id="login-container" class="login-container" style="display: none;"> <div id="login-container" class="login-container" style="display: none;">
<div class="login-box"> <div class="login-box">
<h2>Login to Emby</h2> <img src="images/sofarr-flashscreen.png" alt="sofarr" class="login-logo">
<p class="login-subtitle">Login with your Emby credentials</p>
<form id="login-form"> <form id="login-form">
<div class="form-group"> <div class="form-group">
<label for="username">Username:</label> <label for="username">Username:</label>
@@ -46,6 +52,13 @@
<option value="0">Off</option> <option value="0">Off</option>
</select> </select>
</div> </div>
<div id="admin-controls" class="admin-controls" style="display: none;">
<label class="toggle-label">
<input type="checkbox" id="show-all-toggle">
<span>Show all users</span>
</label>
<button id="status-btn" class="status-btn">Status</button>
</div>
<div class="user-info"> <div class="user-info">
<span class="user-label">Current User:</span> <span class="user-label">Current User:</span>
<span class="user-name" id="currentUser">-</span> <span class="user-name" id="currentUser">-</span>
@@ -54,6 +67,8 @@
</div> </div>
</header> </header>
<div id="status-panel" class="status-panel" style="display: none;"></div>
<div id="error-message" class="error-message" style="display: none;"></div> <div id="error-message" class="error-message" style="display: none;"></div>
<div id="loading" class="loading" style="display: none;">Loading downloads...</div> <div id="loading" class="loading" style="display: none;">Loading downloads...</div>
+454 -9
View File
@@ -1,3 +1,34 @@
/* ===== Splash Screen ===== */
.splash-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
z-index: 9999;
opacity: 1;
transition: opacity 0.4s ease-out;
}
.splash-screen.fade-out {
opacity: 0;
}
.splash-logo {
max-width: 280px;
width: 60%;
animation: splashPulse 1.8s ease-in-out infinite;
}
@keyframes splashPulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.03); opacity: 0.85; }
}
/* ===== Theme Variables ===== */ /* ===== Theme Variables ===== */
:root, [data-theme="light"] { :root, [data-theme="light"] {
--bg-gradient-start: #667eea; --bg-gradient-start: #667eea;
@@ -347,8 +378,9 @@ body {
.download-header { .download-header {
display: flex; display: flex;
justify-content: space-between; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 4px;
margin-bottom: 4px; margin-bottom: 4px;
} }
@@ -407,9 +439,9 @@ body {
margin-bottom: 2px; margin-bottom: 2px;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
white-space: nowrap; overflow-wrap: break-word;
word-break: break-word;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
} }
.download-series, .download-series,
@@ -492,7 +524,8 @@ body {
color: var(--danger); color: var(--danger);
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 500; font-weight: 500;
white-space: nowrap; overflow-wrap: break-word;
word-break: break-word;
} }
/* ===== Footer ===== */ /* ===== Footer ===== */
@@ -514,6 +547,12 @@ body {
align-items: center; align-items: center;
min-height: 100vh; min-height: 100vh;
padding: 20px; padding: 20px;
opacity: 1;
transition: opacity 0.3s ease-out;
}
.login-container.fade-out {
opacity: 0;
} }
.login-box { .login-box {
@@ -524,17 +563,24 @@ body {
width: 100%; width: 100%;
max-width: 380px; max-width: 380px;
transition: background 0.3s; transition: background 0.3s;
text-align: center;
} }
.login-box h2 { .login-logo {
color: var(--text-primary); max-width: 180px;
width: 60%;
margin-bottom: 12px;
}
.login-subtitle {
color: var(--text-secondary);
margin-bottom: 24px; margin-bottom: 24px;
text-align: center; font-size: 0.85rem;
font-size: 1.5rem;
} }
.form-group { .form-group {
margin-bottom: 16px; margin-bottom: 16px;
text-align: left;
} }
.form-group label { .form-group label {
@@ -577,19 +623,358 @@ body {
background: var(--accent-hover); background: var(--accent-hover);
} }
/* ===== Admin Controls ===== */
.admin-controls {
display: flex;
align-items: center;
}
.toggle-label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 0.8rem;
color: var(--text-secondary);
background: var(--surface-alt);
padding: 4px 12px;
border-radius: 14px;
}
.toggle-label input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: var(--accent);
}
/* ===== Arr Links (Admin) ===== */
.arr-link {
color: var(--accent);
text-decoration: none;
border-bottom: 1px dotted var(--accent);
}
.arr-link:hover {
opacity: 0.8;
border-bottom-style: solid;
}
/* ===== Download Paths (Admin) ===== */
.download-paths {
flex-basis: 100%;
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 2px;
}
.path-item {
font-size: 0.7rem;
font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace;
color: var(--text-muted);
overflow-wrap: break-word;
word-break: break-all;
overflow: hidden;
}
.path-label {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.path-value {
color: var(--text-muted);
}
/* ===== Import Issue Badge ===== */
.import-issue-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 0.65rem;
font-weight: 600;
background: #ffebee;
color: #c62828;
cursor: help;
position: relative;
white-space: nowrap;
}
.import-issue-badge:hover::after {
content: attr(data-tooltip);
position: absolute;
top: calc(100% + 6px);
left: 0;
background: #424242;
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.7rem;
font-weight: 400;
white-space: pre-line;
max-width: 320px;
z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
line-height: 1.4;
pointer-events: none;
}
.download-user-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 0.65rem;
font-weight: 600;
text-transform: capitalize;
background: var(--accent-light);
color: var(--accent);
margin-left: auto;
white-space: nowrap;
}
/* ===== Status Button ===== */
.status-btn {
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--surface);
color: var(--text-secondary);
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
transition: background 0.2s, color 0.2s;
}
.status-btn:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
}
/* ===== Status Panel ===== */
.status-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 2px 4px var(--shadow);
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.status-header h3 {
margin: 0;
color: var(--text-primary);
font-size: 1.1rem;
}
.status-close {
background: none;
border: none;
font-size: 1.4rem;
color: var(--text-secondary);
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.status-close:hover {
color: var(--text-primary);
}
.status-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.status-card {
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
}
.status-card-wide {
grid-column: 1 / -1;
}
.status-card-title {
font-weight: 600;
font-size: 0.85rem;
color: var(--text-primary);
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.status-row {
display: flex;
justify-content: space-between;
padding: 3px 0;
font-size: 0.8rem;
color: var(--text-secondary);
}
.status-row span:last-child {
font-weight: 500;
color: var(--text-primary);
}
.status-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.status-table th {
text-align: left;
padding: 4px 8px;
color: var(--text-secondary);
font-weight: 600;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.3px;
border-bottom: 1px solid var(--border);
}
.status-table td {
padding: 5px 8px;
color: var(--text-primary);
border-bottom: 1px solid var(--border);
}
.status-table code {
font-size: 0.75rem;
background: var(--surface);
padding: 1px 4px;
border-radius: 3px;
}
.status-expired {
color: #c62828;
font-weight: 600;
font-size: 0.7rem;
}
.status-fg-badge {
background: #fff3e0;
color: #e65100;
padding: 1px 8px;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 600;
}
.status-row-sub {
padding-left: 12px;
font-size: 0.75rem;
opacity: 0.8;
}
.status-row-sub span:first-child {
font-style: italic;
}
.status-timings {
display: flex;
flex-direction: column;
gap: 6px;
}
.timing-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.78rem;
}
.timing-label {
width: 110px;
flex-shrink: 0;
color: var(--text-secondary);
white-space: nowrap;
}
.timing-bar-bg {
flex: 1;
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
}
.timing-bar {
height: 100%;
background: var(--accent);
border-radius: 4px;
min-width: 2px;
transition: width 0.3s ease;
}
.timing-value {
width: 50px;
flex-shrink: 0;
text-align: right;
color: var(--text-primary);
font-weight: 500;
font-size: 0.75rem;
}
.status-loading, .status-error {
text-align: center;
padding: 20px;
color: var(--text-secondary);
font-size: 0.9rem;
}
.status-error {
color: #c62828;
}
/* ===== Mobile ===== */ /* ===== Mobile ===== */
@media (max-width: 768px) { @media (max-width: 768px) {
.app {
padding: 10px;
}
.app-header { .app-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
padding: 12px 14px; padding: 10px 12px;
gap: 8px;
} }
.header-controls { .header-controls {
width: 100%;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.user-info {
width: 100%;
justify-content: space-between;
box-sizing: border-box;
}
.admin-controls {
width: 100%;
flex-wrap: wrap;
gap: 6px;
}
.downloads-container {
padding: 12px;
}
.download-card { .download-card {
padding: 8px 10px; padding: 8px 10px;
} }
@@ -606,4 +991,64 @@ body {
.progress-container { .progress-container {
flex-wrap: wrap; flex-wrap: wrap;
} }
.status-grid {
grid-template-columns: 1fr;
}
.status-table {
font-size: 0.72rem;
}
.status-table th,
.status-table td {
padding: 4px 4px;
}
.status-table td code {
word-break: break-all;
}
.timing-label {
width: 90px;
}
.timing-value {
width: 40px;
}
.import-issue-badge:hover::after {
left: auto;
right: 0;
max-width: calc(100vw - 24px);
}
}
/* ===== Very small screens (≤ 400px) ===== */
@media (max-width: 400px) {
.app {
padding: 6px;
}
.download-cover {
display: none;
}
.theme-switcher {
flex-shrink: 0;
}
.user-info {
font-size: 0.78rem;
padding: 5px 10px;
}
.download-card {
padding: 8px;
}
.timing-label {
width: 75px;
font-size: 0.7rem;
}
} }
+3
View File
@@ -54,6 +54,7 @@ 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 authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
@@ -79,5 +80,7 @@ app.listen(PORT, () => {
console.log(` sofarr - Your Downloads Dashboard`); console.log(` sofarr - Your Downloads Dashboard`);
console.log(` Server running on port ${PORT}`); console.log(` Server running on port ${PORT}`);
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(`=================================`); console.log(`=================================`);
startPoller();
}); });
+6 -2
View File
@@ -37,9 +37,11 @@ router.post('/login', async (req, res) => {
console.log(`[Auth] Login successful for user: ${user.Name}`); console.log(`[Auth] Login successful for user: ${user.Name}`);
// Set authentication cookie // Set authentication cookie
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
res.cookie('emby_user', JSON.stringify({ res.cookie('emby_user', JSON.stringify({
id: user.Id, id: user.Id,
name: user.Name, name: user.Name,
isAdmin: isAdmin,
token: authData.AccessToken token: authData.AccessToken
}), { }), {
httpOnly: true, httpOnly: true,
@@ -50,7 +52,8 @@ router.post('/login', async (req, res) => {
success: true, success: true,
user: { user: {
id: user.Id, id: user.Id,
name: user.Name name: user.Name,
isAdmin: isAdmin
} }
}); });
} catch (error) { } catch (error) {
@@ -76,7 +79,8 @@ router.get('/me', (req, res) => {
authenticated: true, authenticated: true,
user: { user: {
id: user.id, id: user.id,
name: user.name name: user.name,
isAdmin: !!user.isAdmin
} }
}); });
} catch (error) { } catch (error) {
+247 -230
View File
@@ -1,13 +1,11 @@
const express = require('express'); const express = require('express');
const axios = require('axios');
const router = express.Router(); const router = express.Router();
const { getTorrents, mapTorrentToDownload } = require('../utils/qbittorrent'); const axios = require('axios');
const { const { mapTorrentToDownload } = require('../utils/qbittorrent');
getSABnzbdInstances, const cache = require('../utils/cache');
getSonarrInstances, const { pollAllServices, getLastPollTimings, POLLING_ENABLED } = require('../utils/poller');
getRadarrInstances const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
} = require('../utils/config');
const EMBY_URL = process.env.EMBY_URL; const EMBY_URL = process.env.EMBY_URL;
const EMBY_API_KEY = process.env.EMBY_API_KEY; const EMBY_API_KEY = process.env.EMBY_API_KEY;
@@ -42,6 +40,71 @@ function extractUserTag(tags, tagMap) {
return userTag ? userTag.label : null; return userTag ? userTag.label : null;
} }
// Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim
function sanitizeTagLabel(input) {
if (!input) return '';
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
// Check if a tag matches the username: exact match first, then sanitized match
function tagMatchesUser(tag, username) {
if (!tag || !username) return false;
const tagLower = tag.toLowerCase();
// Exact match (handles users whose tags weren't mangled)
if (tagLower === username) return true;
// Sanitized match (handles Ombi-mangled tags for email-style usernames)
if (tagLower === sanitizeTagLabel(username)) return true;
return false;
}
// Extract import issues from a Sonarr/Radarr queue record
function getImportIssues(queueRecord) {
if (!queueRecord) return null;
const state = queueRecord.trackedDownloadState;
const status = queueRecord.trackedDownloadStatus;
if (state !== 'importPending' && status !== 'warning' && status !== 'error') return null;
const messages = [];
if (queueRecord.statusMessages && queueRecord.statusMessages.length > 0) {
for (const sm of queueRecord.statusMessages) {
if (sm.messages && sm.messages.length > 0) {
messages.push(...sm.messages);
} else if (sm.title) {
messages.push(sm.title);
}
}
}
if (queueRecord.errorMessage) {
messages.push(queueRecord.errorMessage);
}
if (messages.length === 0) return null;
return messages;
}
// Helper to build Sonarr web UI link for a series
function getSonarrLink(series) {
if (!series || !series._instanceUrl || !series.titleSlug) return null;
return `${series._instanceUrl}/series/${series.titleSlug}`;
}
// Helper to build Radarr web UI link for a movie
function getRadarrLink(movie) {
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
}
// Track active dashboard clients: Map<username, { refreshRateMs, lastSeen }>
const activeClients = new Map();
const CLIENT_STALE_MS = 30000; // consider client gone after 30s of no requests
function getActiveClients() {
const now = Date.now();
// Prune stale clients
for (const [key, client] of activeClients.entries()) {
if (now - client.lastSeen > CLIENT_STALE_MS) activeClients.delete(key);
}
return Array.from(activeClients.values());
}
// Get user downloads for authenticated user // Get user downloads for authenticated user
router.get('/user-downloads', async (req, res) => { router.get('/user-downloads', async (req, res) => {
try { try {
@@ -53,209 +116,70 @@ router.get('/user-downloads', async (req, res) => {
const user = JSON.parse(userCookie); const user = JSON.parse(userCookie);
const username = user.name.toLowerCase(); const username = user.name.toLowerCase();
console.log(`[Dashboard] Fetching downloads for authenticated user: ${user.name} (${username})`); const usernameSanitized = sanitizeTagLabel(user.name);
const isAdmin = !!user.isAdmin;
const showAll = isAdmin && req.query.showAll === 'true';
console.log(`[Dashboard] Serving downloads for user: ${user.name} (${username}), isAdmin: ${isAdmin}, showAll: ${showAll}`);
// Get all service instances // Track this client's refresh rate
const sabInstances = getSABnzbdInstances(); const clientRefreshRate = parseInt(req.query.refreshRate, 10);
const sonarrInstances = getSonarrInstances(); if (clientRefreshRate > 0) {
const radarrInstances = getRadarrInstances(); activeClients.set(username, { user: user.name, refreshRateMs: clientRefreshRate, lastSeen: Date.now() });
} else {
console.log(`[Dashboard] Fetching data from all services...`); // Client has refresh off or didn't send — still mark as seen but with no rate
console.log(`[Dashboard] SABnzbd instances: ${sabInstances.length}`); activeClients.set(username, { user: user.name, refreshRateMs: 0, lastSeen: Date.now() });
console.log(`[Dashboard] Sonarr instances: ${sonarrInstances.length}`); }
console.log(`[Dashboard] Radarr instances: ${radarrInstances.length}`);
// When polling is disabled, fetch on-demand if cache has expired
// Fetch from all SABnzbd instances // The fetched data is cached (30s TTL) so subsequent requests from any user reuse it
const sabQueuePromises = sabInstances.map(inst => if (!POLLING_ENABLED && !cache.get('poll:sab-queue')) {
axios.get(`${inst.url}/api`, { console.log(`[Dashboard] Cache expired and polling disabled, fetching on-demand...`);
params: { mode: 'queue', apikey: inst.apiKey, output: 'json' } await pollAllServices();
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => { }
console.error(`[Dashboard] SABnzbd ${inst.id} queue error:`, err.message);
return { instance: inst.id, data: { queue: { slots: [] } } }; // Read all data from cache
}) const sabQueueData = cache.get('poll:sab-queue') || { slots: [] };
); const sabHistoryData = cache.get('poll:sab-history') || { slots: [] };
const sonarrTagsResults = cache.get('poll:sonarr-tags') || [];
const sabHistoryPromises = sabInstances.map(inst => const sonarrQueueData = cache.get('poll:sonarr-queue') || { records: [] };
axios.get(`${inst.url}/api`, { const sonarrHistoryData = cache.get('poll:sonarr-history') || { records: [] };
params: { mode: 'history', apikey: inst.apiKey, output: 'json', limit: 100 } const radarrQueueData = cache.get('poll:radarr-queue') || { records: [] };
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => { const radarrHistoryData = cache.get('poll:radarr-history') || { records: [] };
console.error(`[Dashboard] SABnzbd ${inst.id} history error:`, err.message); const radarrTagsData = cache.get('poll:radarr-tags') || [];
return { instance: inst.id, data: { history: { slots: [] } } }; const qbittorrentTorrents = cache.get('poll:qbittorrent') || [];
})
); // Wrap in the structure the rest of the code expects
const sabnzbdQueue = { data: { queue: sabQueueData } };
// Fetch from all Sonarr instances const sabnzbdHistory = { data: { history: sabHistoryData } };
const sonarrTagsPromises = sonarrInstances.map(inst => const sonarrQueue = { data: sonarrQueueData };
axios.get(`${inst.url}/api/v3/tag`, { const sonarrHistory = { data: sonarrHistoryData };
headers: { 'X-Api-Key': inst.apiKey } const radarrQueue = { data: radarrQueueData };
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => { const radarrHistory = { data: radarrHistoryData };
console.error(`[Dashboard] Sonarr ${inst.id} tags error:`, err.message); const radarrTags = { data: radarrTagsData };
return { instance: inst.id, data: [] };
}) // Build series/movie maps from embedded objects in queue records
); // (history is fetched without includeSeries/includeMovie for speed;
// history matches fall back to the queue-built map via seriesId/movieId)
const sonarrQueuePromises = sonarrInstances.map(inst => const seriesMap = new Map();
axios.get(`${inst.url}/api/v3/queue`, { for (const r of sonarrQueue.data.records) {
headers: { 'X-Api-Key': inst.apiKey }, if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
params: { includeSeries: true, includeEpisode: true } }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => { for (const r of sonarrHistory.data.records) {
console.error(`[Dashboard] Sonarr ${inst.id} queue error:`, err.message); if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
return { instance: inst.id, data: { records: [] } }; }
}) const moviesMap = new Map();
); for (const r of radarrQueue.data.records) {
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
const sonarrHistoryPromises = sonarrInstances.map(inst => }
axios.get(`${inst.url}/api/v3/history`, { for (const r of radarrHistory.data.records) {
headers: { 'X-Api-Key': inst.apiKey }, if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
params: { pageSize: 100, includeSeries: true, includeEpisode: true } }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Dashboard] Sonarr ${inst.id} history error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
);
const sonarrSeriesPromises = sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/series`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Dashboard] Sonarr ${inst.id} series error:`, err.message);
return { instance: inst.id, data: [] };
})
);
// Fetch from all Radarr instances
const radarrQueuePromises = radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/queue`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { includeMovie: true }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Dashboard] Radarr ${inst.id} queue error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
);
const radarrHistoryPromises = radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/history`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { pageSize: 100, includeMovie: true }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Dashboard] Radarr ${inst.id} history error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
);
const radarrMoviesPromises = radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/movie`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Dashboard] Radarr ${inst.id} movies error:`, err.message);
return { instance: inst.id, data: [] };
})
);
const radarrTagsPromises = radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/tag`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Dashboard] Radarr ${inst.id} tags error:`, err.message);
return { instance: inst.id, data: [] };
})
);
// Execute all requests
const [
sabQueues, sabHistories, sonarrTagsResults, sonarrQueues, sonarrHistories, sonarrSeriesResults,
radarrQueues, radarrHistories, radarrMoviesResults, radarrTagsResults,
qbittorrentTorrents
] = await Promise.all([
Promise.all(sabQueuePromises),
Promise.all(sabHistoryPromises),
Promise.all(sonarrTagsPromises),
Promise.all(sonarrQueuePromises),
Promise.all(sonarrHistoryPromises),
Promise.all(sonarrSeriesPromises),
Promise.all(radarrQueuePromises),
Promise.all(radarrHistoryPromises),
Promise.all(radarrMoviesPromises),
Promise.all(radarrTagsPromises),
getTorrents().catch(err => {
console.error(`[Dashboard] qBittorrent error:`, err.message);
return [];
})
]);
// Aggregate data from all instances
const firstSabQueue = sabQueues[0] && sabQueues[0].data && sabQueues[0].data.queue;
const sabnzbdQueue = {
data: {
queue: {
slots: sabQueues.flatMap(q => (q.data.queue && q.data.queue.slots) || []),
status: firstSabQueue && firstSabQueue.status,
speed: firstSabQueue && firstSabQueue.speed,
kbpersec: firstSabQueue && firstSabQueue.kbpersec
}
}
};
const sabnzbdHistory = {
data: {
history: {
slots: sabHistories.flatMap(h => (h.data.history && h.data.history.slots) || [])
}
}
};
const sonarrQueue = {
data: {
records: sonarrQueues.flatMap(q => q.data.records || [])
}
};
const sonarrHistory = {
data: {
records: sonarrHistories.flatMap(h => h.data.records || [])
}
};
const sonarrSeries = {
data: sonarrSeriesResults.flatMap(s => s.data || [])
};
const radarrQueue = {
data: {
records: radarrQueues.flatMap(q => q.data.records || [])
}
};
const radarrHistory = {
data: {
records: radarrHistories.flatMap(h => h.data.records || [])
}
};
const radarrMovies = {
data: radarrMoviesResults.flatMap(m => m.data || [])
};
const radarrTags = {
data: radarrTagsResults.flatMap(t => t.data || [])
};
console.log(`[Dashboard] Data fetched successfully`);
console.log(`[Dashboard] Sonarr series: ${sonarrSeries.data.length}`);
console.log(`[Dashboard] Radarr movies: ${radarrMovies.data.length}`);
console.log(`[Dashboard] Radarr queue records: ${radarrQueue.data.records.length}`);
console.log(`[Dashboard] Radarr history records: ${radarrHistory.data.records.length}`);
console.log(`[Dashboard] SABnzbd queue slots: ${sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.slots.length : 0}`);
console.log(`[Dashboard] SABnzbd history slots: ${sabnzbdHistory.data.history ? sabnzbdHistory.data.history.slots.length : 0}`);
console.log(`[Dashboard] qBittorrent torrents: ${qbittorrentTorrents.length}`);
console.log(`[Dashboard] Radarr queue:`, JSON.stringify(radarrQueue.data.records));
console.log(`[Dashboard] Radarr history:`, JSON.stringify(radarrHistory.data.records));
// Create maps for quick lookup
const seriesMap = new Map(sonarrSeries.data.map(s => [s.id, s]));
const moviesMap = new Map(radarrMovies.data.map(m => [m.id, m]));
// Create tag maps (id -> label) // Create tag maps (id -> label)
const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label])); const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label])); const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label]));
console.log(`[Dashboard] Radarr tags:`, JSON.stringify(radarrTags.data));
console.log(`[Dashboard] Movies map keys:`, Array.from(moviesMap.keys()).slice(0, 10)); console.log(`[Dashboard] Cache data - Series: ${seriesMap.size}, Movies: ${moviesMap.size}, qBit: ${qbittorrentTorrents.length}`);
console.log(`[Dashboard] Looking for movieId: 2962`);
console.log(`[Dashboard] Movie 2962:`, JSON.stringify(moviesMap.get(2962)));
console.log(`[Dashboard] Sample movie structure:`, JSON.stringify(radarrMovies.data[0]));
// Match SABnzbd downloads to Sonarr/Radarr activity // Match SABnzbd downloads to Sonarr/Radarr activity
const userDownloads = []; const userDownloads = [];
@@ -301,8 +225,8 @@ router.get('/user-downloads', async (req, res) => {
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series; const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) { if (series) {
const userTag = extractUserTag(series.tags, sonarrTagMap); const userTag = extractUserTag(series.tags, sonarrTagMap);
if (userTag && userTag.toLowerCase() === username) { if (userTag && (showAll || tagMatchesUser(userTag, username))) {
userDownloads.push({ const dlObj = {
type: 'series', type: 'series',
title: nzbName, title: nzbName,
coverArt: getCoverArt(series), coverArt: getCoverArt(series),
@@ -314,8 +238,17 @@ router.get('/user-downloads', async (req, res) => {
speed: slotState.speed, speed: slotState.speed,
eta: slot.timeleft, eta: slot.timeleft,
seriesName: series.title, seriesName: series.title,
episodeInfo: sonarrMatch episodeInfo: sonarrMatch,
}); userTag: userTag
};
const issues = getImportIssues(sonarrMatch);
if (issues) dlObj.importIssues = issues;
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = series.path || null;
dlObj.arrLink = getSonarrLink(series);
}
userDownloads.push(dlObj);
} }
} }
} }
@@ -330,8 +263,8 @@ router.get('/user-downloads', async (req, res) => {
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie; const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
if (movie) { if (movie) {
const userTag = extractUserTag(movie.tags, radarrTagMap); const userTag = extractUserTag(movie.tags, radarrTagMap);
if (userTag && userTag.toLowerCase() === username) { if (userTag && (showAll || tagMatchesUser(userTag, username))) {
userDownloads.push({ const dlObj = {
type: 'movie', type: 'movie',
title: nzbName, title: nzbName,
coverArt: getCoverArt(movie), coverArt: getCoverArt(movie),
@@ -343,8 +276,17 @@ router.get('/user-downloads', async (req, res) => {
speed: slotState.speed, speed: slotState.speed,
eta: slot.timeleft, eta: slot.timeleft,
movieName: movie.title, movieName: movie.title,
movieInfo: radarrMatch movieInfo: radarrMatch,
}); userTag: userTag
};
const issues = getImportIssues(radarrMatch);
if (issues) dlObj.importIssues = issues;
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = movie.path || null;
dlObj.arrLink = getRadarrLink(movie);
}
userDownloads.push(dlObj);
} }
} }
} }
@@ -376,8 +318,8 @@ router.get('/user-downloads', async (req, res) => {
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series; const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) { if (series) {
const userTag = extractUserTag(series.tags, sonarrTagMap); const userTag = extractUserTag(series.tags, sonarrTagMap);
if (userTag && userTag.toLowerCase() === username) { if (userTag && (showAll || tagMatchesUser(userTag, username))) {
userDownloads.push({ const dlObj = {
type: 'series', type: 'series',
title: nzbName, title: nzbName,
coverArt: getCoverArt(series), coverArt: getCoverArt(series),
@@ -385,8 +327,15 @@ router.get('/user-downloads', 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 episodeInfo: sonarrMatch,
}); userTag: userTag
};
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = series.path || null;
dlObj.arrLink = getSonarrLink(series);
}
userDownloads.push(dlObj);
} }
} }
} }
@@ -401,8 +350,8 @@ router.get('/user-downloads', async (req, res) => {
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie; const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
if (movie) { if (movie) {
const userTag = extractUserTag(movie.tags, radarrTagMap); const userTag = extractUserTag(movie.tags, radarrTagMap);
if (userTag && userTag.toLowerCase() === username) { if (userTag && (showAll || tagMatchesUser(userTag, username))) {
userDownloads.push({ const dlObj = {
type: 'movie', type: 'movie',
title: nzbName, title: nzbName,
coverArt: getCoverArt(movie), coverArt: getCoverArt(movie),
@@ -410,8 +359,15 @@ router.get('/user-downloads', async (req, res) => {
size: slot.size, size: slot.size,
completedAt: slot.completed_time, completedAt: slot.completed_time,
movieName: movie.title, movieName: movie.title,
movieInfo: radarrMatch movieInfo: radarrMatch,
}); userTag: userTag
};
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = movie.path || null;
dlObj.arrLink = getRadarrLink(movie);
}
userDownloads.push(dlObj);
} }
} }
} }
@@ -430,14 +386,14 @@ router.get('/user-downloads', async (req, res) => {
console.log(`[Dashboard] Sonarr tag map:`, Array.from(sonarrTagMap.entries())); console.log(`[Dashboard] Sonarr tag map:`, Array.from(sonarrTagMap.entries()));
console.log(`[Dashboard] Radarr tag map:`, Array.from(radarrTagMap.entries())); console.log(`[Dashboard] Radarr tag map:`, Array.from(radarrTagMap.entries()));
// Show movies/series tagged for this user // Show movies/series tagged for this user (from embedded objects in queue/history)
const userMovies = radarrMovies.data.filter(m => { const userMovies = Array.from(moviesMap.values()).filter(m => {
const tag = extractUserTag(m.tags, radarrTagMap); const tag = extractUserTag(m.tags, radarrTagMap);
return tag && tag.toLowerCase() === username; return tag && tagMatchesUser(tag, username);
}); });
const userSeries = sonarrSeries.data.filter(s => { const userSeries = Array.from(seriesMap.values()).filter(s => {
const tag = extractUserTag(s.tags, sonarrTagMap); const tag = extractUserTag(s.tags, sonarrTagMap);
return tag && tag.toLowerCase() === username; return tag && tagMatchesUser(tag, username);
}); });
console.log(`[Dashboard] Movies tagged for ${username}:`, userMovies.map(m => m.title)); console.log(`[Dashboard] Movies tagged for ${username}:`, userMovies.map(m => m.title));
console.log(`[Dashboard] Series tagged for ${username}:`, userSeries.map(s => s.title)); console.log(`[Dashboard] Series tagged for ${username}:`, userSeries.map(s => s.title));
@@ -462,7 +418,7 @@ router.get('/user-downloads', async (req, res) => {
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series; const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) { if (series) {
const userTag = extractUserTag(series.tags, sonarrTagMap); const userTag = extractUserTag(series.tags, sonarrTagMap);
if (userTag && userTag.toLowerCase() === username) { if (userTag && (showAll || tagMatchesUser(userTag, username))) {
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr series "${series.title}"`); console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr series "${series.title}"`);
const download = mapTorrentToDownload(torrent); const download = mapTorrentToDownload(torrent);
download.type = 'series'; download.type = 'series';
@@ -470,6 +426,13 @@ router.get('/user-downloads', async (req, res) => {
download.seriesName = series.title; download.seriesName = series.title;
download.episodeInfo = sonarrMatch; download.episodeInfo = sonarrMatch;
download.userTag = userTag; download.userTag = userTag;
const sonarrIssues = getImportIssues(sonarrMatch);
if (sonarrIssues) download.importIssues = sonarrIssues;
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = series.path || null;
download.arrLink = getSonarrLink(series);
}
userDownloads.push(download); userDownloads.push(download);
continue; // Skip to next torrent continue; // Skip to next torrent
} }
@@ -486,7 +449,7 @@ router.get('/user-downloads', async (req, res) => {
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie; const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
if (movie) { if (movie) {
const userTag = extractUserTag(movie.tags, radarrTagMap); const userTag = extractUserTag(movie.tags, radarrTagMap);
if (userTag && userTag.toLowerCase() === username) { if (userTag && (showAll || tagMatchesUser(userTag, username))) {
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr movie "${movie.title}"`); console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr movie "${movie.title}"`);
const download = mapTorrentToDownload(torrent); const download = mapTorrentToDownload(torrent);
download.type = 'movie'; download.type = 'movie';
@@ -494,6 +457,13 @@ router.get('/user-downloads', async (req, res) => {
download.movieName = movie.title; download.movieName = movie.title;
download.movieInfo = radarrMatch; download.movieInfo = radarrMatch;
download.userTag = userTag; download.userTag = userTag;
const radarrIssues = getImportIssues(radarrMatch);
if (radarrIssues) download.importIssues = radarrIssues;
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = movie.path || null;
download.arrLink = getRadarrLink(movie);
}
userDownloads.push(download); userDownloads.push(download);
continue; // Skip to next torrent continue; // Skip to next torrent
} }
@@ -510,7 +480,7 @@ router.get('/user-downloads', async (req, res) => {
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series; const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
if (series) { if (series) {
const userTag = extractUserTag(series.tags, sonarrTagMap); const userTag = extractUserTag(series.tags, sonarrTagMap);
if (userTag && userTag.toLowerCase() === username) { if (userTag && (showAll || tagMatchesUser(userTag, username))) {
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr history "${series.title}"`); console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr history "${series.title}"`);
const download = mapTorrentToDownload(torrent); const download = mapTorrentToDownload(torrent);
download.type = 'series'; download.type = 'series';
@@ -518,6 +488,11 @@ router.get('/user-downloads', async (req, res) => {
download.seriesName = series.title; download.seriesName = series.title;
download.episodeInfo = sonarrHistoryMatch; download.episodeInfo = sonarrHistoryMatch;
download.userTag = userTag; download.userTag = userTag;
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = series.path || null;
download.arrLink = getSonarrLink(series);
}
userDownloads.push(download); userDownloads.push(download);
continue; continue;
} }
@@ -534,7 +509,7 @@ router.get('/user-downloads', async (req, res) => {
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie; const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
if (movie) { if (movie) {
const userTag = extractUserTag(movie.tags, radarrTagMap); const userTag = extractUserTag(movie.tags, radarrTagMap);
if (userTag && userTag.toLowerCase() === username) { if (userTag && (showAll || tagMatchesUser(userTag, username))) {
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr history "${movie.title}"`); console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr history "${movie.title}"`);
const download = mapTorrentToDownload(torrent); const download = mapTorrentToDownload(torrent);
download.type = 'movie'; download.type = 'movie';
@@ -542,6 +517,11 @@ router.get('/user-downloads', async (req, res) => {
download.movieName = movie.title; download.movieName = movie.title;
download.movieInfo = radarrHistoryMatch; download.movieInfo = radarrHistoryMatch;
download.userTag = userTag; download.userTag = userTag;
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = movie.path || null;
download.arrLink = getRadarrLink(movie);
}
userDownloads.push(download); userDownloads.push(download);
continue; continue;
} }
@@ -561,6 +541,7 @@ router.get('/user-downloads', async (req, res) => {
res.json({ res.json({
user: user.name, user: user.name,
isAdmin: isAdmin,
downloads: userDownloads downloads: userDownloads
}); });
} catch (error) { } catch (error) {
@@ -648,4 +629,40 @@ router.get('/user-summary', async (req, res) => {
} }
}); });
// Admin-only status page with cache stats
router.get('/status', (req, res) => {
try {
const userCookie = req.cookies.emby_user;
if (!userCookie) {
return res.status(401).json({ error: 'Not authenticated' });
}
const user = JSON.parse(userCookie);
if (!user.isAdmin) {
return res.status(403).json({ error: 'Admin access required' });
}
const cacheStats = cache.getStats();
const uptime = process.uptime();
res.json({
server: {
uptimeSeconds: Math.floor(uptime),
nodeVersion: process.version,
memoryUsageMB: Math.round(process.memoryUsage().rss / 1024 / 1024 * 10) / 10,
heapUsedMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024 * 10) / 10,
heapTotalMB: Math.round(process.memoryUsage().heapTotal / 1024 / 1024 * 10) / 10
},
polling: {
enabled: POLLING_ENABLED,
intervalMs: POLLING_ENABLED ? require('../utils/poller').POLL_INTERVAL : 0,
lastPoll: getLastPollTimings()
},
cache: cacheStats,
clients: getActiveClients()
});
} catch (err) {
res.status(500).json({ error: 'Failed to get status', details: err.message });
}
});
module.exports = router; module.exports = router;
+70
View File
@@ -0,0 +1,70 @@
const { logToFile } = require('./logger');
class MemoryCache {
constructor() {
this.store = new Map();
}
get(key) {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value;
}
set(key, value, ttlMs) {
this.store.set(key, {
value,
expiresAt: Date.now() + ttlMs
});
}
invalidate(key) {
this.store.delete(key);
}
clear() {
this.store.clear();
}
getStats() {
const now = Date.now();
const entries = [];
let totalSize = 0;
for (const [key, entry] of this.store.entries()) {
const json = JSON.stringify(entry.value);
const sizeBytes = Buffer.byteLength(json, 'utf8');
totalSize += sizeBytes;
const ttlRemaining = Math.max(0, entry.expiresAt - now);
const expired = now > entry.expiresAt;
let itemCount = null;
if (Array.isArray(entry.value)) {
itemCount = entry.value.length;
} else if (entry.value && typeof entry.value === 'object') {
if (Array.isArray(entry.value.records)) itemCount = entry.value.records.length;
else if (Array.isArray(entry.value.slots)) itemCount = entry.value.slots.length;
}
entries.push({
key,
sizeBytes,
itemCount,
ttlRemainingMs: ttlRemaining,
expired
});
}
return {
entryCount: this.store.size,
totalSizeBytes: totalSize,
entries
};
}
}
const cache = new MemoryCache();
module.exports = cache;
+219
View File
@@ -0,0 +1,219 @@
const axios = require('axios');
const cache = require('./cache');
const { getTorrents } = require('./qbittorrent');
const {
getSABnzbdInstances,
getSonarrInstances,
getRadarrInstances
} = require('./config');
const rawPollInterval = (process.env.POLL_INTERVAL || '').toLowerCase();
const POLL_INTERVAL = (rawPollInterval === 'off' || rawPollInterval === 'false' || rawPollInterval === 'disabled')
? 0
: (parseInt(process.env.POLL_INTERVAL, 10) || 5000);
const POLLING_ENABLED = POLL_INTERVAL > 0;
let polling = false;
let lastPollTimings = null;
// Timed fetch helper: runs a fetch and records how long it took
async function timed(label, fn) {
const t0 = Date.now();
const result = await fn();
return { label, result, ms: Date.now() - t0 };
}
async function pollAllServices() {
if (polling) {
console.log('[Poller] Previous poll still running, skipping');
return;
}
polling = true;
const start = Date.now();
try {
const sabInstances = getSABnzbdInstances();
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
// All fetches in parallel, each individually timed
const results = await Promise.all([
timed('SABnzbd Queue', () => Promise.all(sabInstances.map(inst =>
axios.get(`${inst.url}/api`, {
params: { mode: 'queue', apikey: inst.apiKey, output: 'json' }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] SABnzbd ${inst.id} queue error:`, err.message);
return { instance: inst.id, data: { queue: { slots: [] } } };
})
))),
timed('SABnzbd History', () => Promise.all(sabInstances.map(inst =>
axios.get(`${inst.url}/api`, {
params: { mode: 'history', apikey: inst.apiKey, output: 'json', limit: 10 }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] SABnzbd ${inst.id} history error:`, err.message);
return { instance: inst.id, data: { history: { slots: [] } } };
})
))),
timed('Sonarr Tags', () => Promise.all(sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/tag`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Sonarr ${inst.id} tags error:`, err.message);
return { instance: inst.id, data: [] };
})
))),
timed('Sonarr Queue', () => Promise.all(sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/queue`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { includeSeries: true }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Sonarr ${inst.id} queue error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
))),
timed('Sonarr History', () => Promise.all(sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/history`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { pageSize: 10 }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Sonarr ${inst.id} history error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
))),
timed('Radarr Queue', () => Promise.all(radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/queue`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { includeMovie: true }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Radarr ${inst.id} queue error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
))),
timed('Radarr History', () => Promise.all(radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/history`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { pageSize: 10 }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Radarr ${inst.id} history error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
))),
timed('Radarr Tags', () => Promise.all(radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/tag`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Poller] Radarr ${inst.id} tags error:`, err.message);
return { instance: inst.id, data: [] };
})
))),
timed('qBittorrent', () => getTorrents().catch(err => {
console.error(`[Poller] qBittorrent error:`, err.message);
return [];
}))
]);
const [
{ result: sabQueues }, { result: sabHistories },
{ result: sonarrTagsResults }, { result: sonarrQueues },
{ result: sonarrHistories },
{ result: radarrQueues }, { result: radarrHistories },
{ result: radarrTagsResults },
{ result: qbittorrentTorrents }
] = results;
// Store per-task timings
const totalMs = Date.now() - start;
lastPollTimings = {
totalMs,
timestamp: new Date().toISOString(),
tasks: results.map(r => ({ label: r.label, ms: r.ms }))
};
// When polling is active, TTL is 3x interval to avoid gaps between polls
// When polling is disabled (on-demand), use 30s so data refreshes on next request after expiry
const cacheTTL = POLLING_ENABLED ? POLL_INTERVAL * 3 : 30000;
// SABnzbd
const firstSabQueue = sabQueues[0] && sabQueues[0].data && sabQueues[0].data.queue;
cache.set('poll:sab-queue', {
slots: sabQueues.flatMap(q => (q.data.queue && q.data.queue.slots) || []),
status: firstSabQueue && firstSabQueue.status,
speed: firstSabQueue && firstSabQueue.speed,
kbpersec: firstSabQueue && firstSabQueue.kbpersec
}, cacheTTL);
cache.set('poll:sab-history', {
slots: sabHistories.flatMap(h => (h.data.history && h.data.history.slots) || [])
}, cacheTTL);
// Sonarr
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
cache.set('poll:sonarr-queue', {
records: sonarrQueues.flatMap(q => {
const inst = sonarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
return (q.data.records || []).map(r => {
if (r.series) r.series._instanceUrl = url;
return r;
});
})
}, cacheTTL);
cache.set('poll:sonarr-history', {
records: sonarrHistories.flatMap(h => h.data.records || [])
}, cacheTTL);
// Radarr
cache.set('poll:radarr-queue', {
records: radarrQueues.flatMap(q => {
const inst = radarrInstances.find(i => i.id === q.instance);
const url = inst ? inst.url : null;
return (q.data.records || []).map(r => {
if (r.movie) r.movie._instanceUrl = url;
return r;
});
})
}, cacheTTL);
cache.set('poll:radarr-history', {
records: radarrHistories.flatMap(h => h.data.records || [])
}, cacheTTL);
cache.set('poll:radarr-tags', radarrTagsResults.flatMap(t => t.data || []), cacheTTL);
// qBittorrent
cache.set('poll:qbittorrent', qbittorrentTorrents, cacheTTL);
const elapsed = Date.now() - start;
console.log(`[Poller] Poll complete in ${elapsed}ms`);
} catch (err) {
console.error(`[Poller] Poll error:`, err.message);
} finally {
polling = false;
}
}
let intervalHandle = null;
function startPoller() {
if (!POLLING_ENABLED) {
console.log(`[Poller] Background polling disabled (POLL_INTERVAL=${process.env.POLL_INTERVAL || 'not set'}). Data will be fetched on-demand.`);
return;
}
console.log(`[Poller] Starting background poller (interval: ${POLL_INTERVAL}ms)`);
// Run immediately, then on interval
pollAllServices();
intervalHandle = setInterval(pollAllServices, POLL_INTERVAL);
}
function stopPoller() {
if (intervalHandle) {
clearInterval(intervalHandle);
intervalHandle = null;
console.log('[Poller] Stopped');
}
}
function getLastPollTimings() {
return lastPollTimings;
}
module.exports = { startPoller, stopPoller, pollAllServices, getLastPollTimings, POLL_INTERVAL, POLLING_ENABLED };
+8 -2
View File
@@ -96,14 +96,19 @@ class QBittorrentClient {
} }
} }
// Persist clients so auth cookies survive between requests
let persistedClients = null;
function getClients() { function getClients() {
if (persistedClients) return persistedClients;
const instances = getQbittorrentInstances(); const instances = getQbittorrentInstances();
if (instances.length === 0) { if (instances.length === 0) {
logToFile('[qBittorrent] No instances configured'); logToFile('[qBittorrent] No instances configured');
return []; return [];
} }
logToFile(`[qBittorrent] Created ${instances.length} client(s)`); logToFile(`[qBittorrent] Created ${instances.length} persistent client(s)`);
return instances.map(inst => new QBittorrentClient(inst)); persistedClients = instances.map(inst => new QBittorrentClient(inst));
return persistedClients;
} }
async function getAllTorrents() { async function getAllTorrents() {
@@ -198,6 +203,7 @@ function mapTorrentToDownload(torrent) {
hash: torrent.hash, hash: torrent.hash,
category: torrent.category, category: torrent.category,
tags: torrent.tags, tags: torrent.tags,
savePath: torrent.content_path || torrent.save_path || null,
qbittorrent: true qbittorrent: true
}; };
} }