feat: fix download-to-user matching, add cover art to downloads

- Fix seriesMap key (use Sonarr internal id, not tvdbId)
- Fix Sonarr tag resolution (use tag map like Radarr)
- Use sourceTitle for history record matching
- Fall back to embedded movie/series objects when API timeouts
- Add includeMovie/includeSeries params to queue/history API calls
- Add coverArt field to all download responses (TMDB poster URLs)
- Add cover art display to frontend download cards
- Fix user-summary route to use instance config and tag maps
This commit is contained in:
2026-05-15 14:54:21 +01:00
parent 5d04d2796b
commit f500f4db3b
18 changed files with 7500 additions and 297 deletions

View File

@@ -1,18 +1,22 @@
# Server Configuration
PORT=3001
# SABnzbd Configuration
SABNZBD_URL=http://localhost:8080
SABNZBD_API_KEY=your_sabnzbd_api_key
# Sonarr Configuration
SONARR_URL=http://localhost:8989
SONARR_API_KEY=your_sonarr_api_key
# Radarr Configuration
RADARR_URL=http://localhost:7878
RADARR_API_KEY=your_radarr_api_key
# Emby Configuration
# Emby Configuration (single instance)
EMBY_URL=http://localhost:8096
EMBY_API_KEY=your_emby_api_key
# SABnzbd Instances (JSON array)
# Format: [{"name": "Instance Name", "url": "http://...", "apiKey": "..."}]
SABNZBD_INSTANCES=[{"name": "Primary", "url": "http://localhost:8080", "apiKey": "your_api_key"}]
# Sonarr Instances (JSON array)
SONARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:8989", "apiKey": "your_api_key"}]
# Radarr Instances (JSON array)
RADARR_INSTANCES=[{"name": "Primary", "url": "http://localhost:7878", "apiKey": "your_api_key"}]
# qBittorrent Instances (JSON array)
QBITTORRENT_INSTANCES=[
{"name": "ransackedcrew", "url": "https://qbittorrent.ransackedcrew.info", "username": "gronod", "password": "K32D&JDjtHA&mC"},
{"name": "i3omb", "url": "https://qbittorrent.i3omb.com", "username": "admin", "password": "b053288369XX!"}
]

77
.env.sample Normal file
View File

@@ -0,0 +1,77 @@
# sofarr Configuration
# Copy this file to .env and update with your values
# =============================================================================
# SERVER SETTINGS
# =============================================================================
PORT=3001
# Logging level: debug, info, warn, error, silent
# - debug: Verbose logging for troubleshooting
# - info: Standard operational logging (default)
# - warn: Only warnings and errors
# - error: Only errors
# - silent: No logging
LOG_LEVEL=info
# =============================================================================
# EMBY (Authentication - Required)
# =============================================================================
EMBY_URL=https://emby.example.com
EMBY_API_KEY=your-emby-api-key-here
# =============================================================================
# SABNZBD INSTANCES (JSON Array Format)
# Add one or more SABnzbd instances as a single-line JSON array
# Format: [{"name":"instance-name","url":"https://...","apiKey":"..."}]
# =============================================================================
SABNZBD_INSTANCES=[{"name":"primary","url":"https://sabnzbd.example.com","apiKey":"your-sabnzbd-api-key"}]
# Legacy single-instance format (optional - still supported)
# SABNZBD_URL=https://sabnzbd.example.com
# SABNZBD_API_KEY=your-sabnzbd-api-key
# =============================================================================
# QBITTORRENT INSTANCES (JSON Array Format)
# Add one or more qBittorrent instances as a single-line JSON array
# Uses username/password authentication (not API key)
# Format: [{"name":"instance-name","url":"https://...","username":"...","password":"..."}]
# =============================================================================
QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"your-password"}]
# Legacy single-instance format (optional - still supported)
# QBITTORRENT_URL=https://qbittorrent.example.com
# QBITTORRENT_USERNAME=admin
# QBITTORRENT_PASSWORD=your-password
# =============================================================================
# SONARR INSTANCES (JSON Array Format)
# Add one or more Sonarr instances as a single-line JSON array
# Format: [{"name":"instance-name","url":"https://...","apiKey":"..."}]
# =============================================================================
SONARR_INSTANCES=[{"name":"main","url":"https://sonarr.example.com","apiKey":"your-sonarr-api-key"}]
# Legacy single-instance format (optional - still supported)
# SONARR_URL=https://sonarr.example.com
# SONARR_API_KEY=your-sonarr-api-key
# =============================================================================
# RADARR INSTANCES (JSON Array Format)
# Add one or more Radarr instances as a single-line JSON array
# Format: [{"name":"instance-name","url":"https://...","apiKey":"..."}]
# =============================================================================
RADARR_INSTANCES=[{"name":"main","url":"https://radarr.example.com","apiKey":"your-radarr-api-key"}]
# Legacy single-instance format (optional - still supported)
# RADARR_URL=https://radarr.example.com
# RADARR_API_KEY=your-radarr-api-key
# =============================================================================
# NOTES
# =============================================================================
# 1. All JSON arrays must be on a single line (no line breaks)
# 2. Instance "name" can be anything descriptive (e.g., "main", "4k", "backup")
# 3. URLs should include protocol (http:// or https://)
# 4. For qBittorrent, ensure Web UI is enabled in settings
# 5. User downloads are matched by tags in Sonarr/Radarr - tag your media!
# =============================================================================

309
README.md
View File

@@ -1,136 +1,219 @@
# Media Download Dashboard
# sofarr
A dashboard for tracking SABnzbd downloads matched to Sonarr/Radarr activity by user, with user identification via Emby server sessions.
> *See your downloads "so far" while you relax on the sofa waiting for your *arr services to finish*
## Features
**sofarr** is a personal media download dashboard that aggregates and displays real-time download progress from all your media automation services. Named for the experience of checking what has downloaded "so far" while you wait comfortably on your "sofa" for Sonarr, Radarr, and your download clients to do their thing!
- **User Identification**: Automatically identifies the current user from active Emby server sessions
- **Download Tracking**: Retrieves download details from SABnzbd
- **Service Integration**: Matches downloads to activity in Sonarr and Radarr
- **User Tag Matching**: Identifies requesting users from tags present against shows or movies
- **Personalized Dashboard**: Presents details of downloading shows/movies for the current user
## What It Does
## Prerequisites
- Node.js (v14 or higher)
- npm
- SABnzbd instance
- Sonarr instance
- Radarr instance
- Emby instance
## Installation
1. Clone the repository and navigate to the project directory
2. Install dependencies:
```bash
npm install
cd client && npm install
cd ..
```
3. Copy the example environment file and configure your services:
```bash
cp .env.example .env
```
Edit `.env` with your service URLs and API keys:
```
PORT=3001
SABNZBD_URL=http://localhost:8080
SABNZBD_API_KEY=your_sabnzbd_api_key
SONARR_URL=http://localhost:8989
SONARR_API_KEY=your_sonarr_api_key
RADARR_URL=http://localhost:7878
RADARR_API_KEY=your_radarr_api_key
EMBY_URL=http://localhost:8096
EMBY_API_KEY=your_emby_api_key
```
## Tagging Your Media
To enable user-specific download tracking, tag your shows and movies in Sonarr/Radarr:
1. In Sonarr/Radarr, go to your series/movie settings
2. Add a tag with the format: `user:username` (e.g., `user:john`)
3. Apply this tag to the series/movies you want to track for that user
The dashboard will match downloads to users based on these tags.
## Usage
Start the development servers:
```bash
npm run dev
```
This will start:
- Backend server on `http://localhost:3001`
- Frontend development server on `http://localhost:5173`
Open your browser and navigate to `http://localhost:5173`
sofarr connects to your media stack and shows you a personalized view of:
- **Active Downloads** - See what's currently downloading from Usenet (SABnzbd) and BitTorrent (qBittorrent)
- **Progress Tracking** - Real-time progress bars with speed, ETA, and completion estimates
- **User Matching** - Downloads are matched to you based on tags in Sonarr/Radarr
- **Multi-Instance Support** - Connect to multiple instances of each service
## How It Works
1. **Session Detection**: The dashboard connects to Emby to detect active user sessions
2. **User Identification**: The current user is identified from their active Emby session
3. **Download Retrieval**: SABnzbd queue and history are retrieved
4. **Activity Matching**: Downloads are matched to Sonarr/Radarr queue and history
5. **Tag Matching**: The user tag from the series/movie is extracted and matched to the current user
6. **Display**: Only downloads matching the current user are displayed
### Architecture Overview
```
┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────┐
│ Browser │────▶│ sofarr │────▶│ SABnzbd (Usenet downloads) │
│ (User) │◀────│ Server │ │ qBittorrent (Torrents) │
└─────────────┘ └──────────────┘ │ Sonarr (TV management) │
│ │ Radarr (Movie management) │
│ │ Emby (User authentication) │
▼ └─────────────────────────────┘
┌──────────────┐
│ Dashboard │
│ Aggregator │
└──────────────┘
```
### The Matching Process
1. **User Authentication**: Login via Emby credentials
2. **Tag-Based Matching**:
- Your media in Sonarr/Radarr is tagged with your username (e.g., "gordon")
- sofarr checks Sonarr/Radarr activity to find items tagged with your name
- Downloads (from SABnzbd/qBittorrent) are matched by title to that activity
- Only your downloads appear on your dashboard
### Multi-Instance Support
sofarr supports multiple instances of each service via JSON array configuration:
```bash
# Single line JSON arrays in .env
QBITTORRENT_INSTANCES=[{"name":"server1","url":"..."},{"name":"server2","url":"..."}]
SONARR_INSTANCES=[{"name":"main","url":"...","apiKey":"..."}]
```
## Prerequisites
- Node.js (v12 or higher)
- npm
- At least one of: SABnzbd or qBittorrent
- Sonarr (optional, for TV tracking)
- Radarr (optional, for movie tracking)
- Emby (for user authentication)
## Installation
1. **Clone and install**:
```bash
git clone <repository-url>
cd sofarr
npm install
```
2. **Configure environment**:
```bash
cp .env.sample .env
# Edit .env with your service details
```
3. **Start the server**:
```bash
npm start
# or for development with auto-restart:
npm run dev
```
4. **Access the dashboard**:
Open `http://localhost:3001` in your browser
## Configuration (.env)
### Basic Server Settings
```bash
PORT=3001 # Server port
LOG_LEVEL=info # Logging: debug, info, warn, error, silent
```
### Service Instances (JSON Array Format)
All services support multi-instance configuration via single-line JSON arrays:
```bash
# SABnzbd Instances
SABNZBD_INSTANCES=[{"name":"primary","url":"https://sabnzbd.example.com","apiKey":"your-api-key"}]
# qBittorrent Instances (uses username/password, not API key)
QBITTORRENT_INSTANCES=[{"name":"main","url":"https://qbittorrent.example.com","username":"admin","password":"secret"}]
# Sonarr Instances
SONARR_INSTANCES=[{"name":"hd","url":"https://sonarr.example.com","apiKey":"your-api-key"}]
# Radarr Instances
RADARR_INSTANCES=[{"name":"movies","url":"https://radarr.example.com","apiKey":"your-api-key"}]
# Emby (single instance for authentication)
EMBY_URL=https://emby.example.com
EMBY_API_KEY=your-emby-api-key
```
### Legacy Single-Instance Format (still supported)
If you only have one instance, you can use the legacy format:
```bash
SABNZBD_URL=https://sabnzbd.example.com
SABNZBD_API_KEY=your-api-key
```
## Setting Up User Tags
To see your downloads, you need to tag your media in Sonarr/Radarr:
1. **In Sonarr** (TV Shows):
- Go to Series → Edit Series
- Add a tag with your username (lowercase)
- Save
2. **In Radarr** (Movies):
- Go to Movies → Edit Movie
- Add a tag with your username (lowercase)
- Save
3. **Result**: When sofarr sees a download matching a show/movie tagged with "gordon", it appears on gordon's dashboard
## Features in Detail
### Real-Time Updates
- Auto-refresh every 5 seconds (configurable: 1s, 5s, 10s, or off)
- In-place DOM updates for smooth UI (no flickering)
### Download Information Displayed
- **Progress bar** with visual completion percentage
- **Speed** - Current download speed
- **ETA** - Estimated time remaining
- **Size** - Total size and downloaded amount
- **Status** - Downloading, Paused, Queued, etc.
- **Instance** - Which server the download is from
### For qBittorrent Downloads
- **Seeds** - Number of seeders
- **Peers** - Number of peers
- **Availability** - Percentage available in swarm
## API Endpoints
### SABnzbd
- `GET /api/sabnzbd/queue` - Get current queue
- `GET /api/sabnzbd/history` - Get download history
### Sonarr
- `GET /api/sonarr/queue` - Get Sonarr queue
- `GET /api/sonarr/history` - Get Sonarr history
- `GET /api/sonarr/series` - Get all series with tags
- `GET /api/sonarr/series/:id` - Get specific series details
### Radarr
- `GET /api/radarr/queue` - Get Radarr queue
- `GET /api/radarr/history` - Get Radarr history
- `GET /api/radarr/movies` - Get all movies with tags
- `GET /api/radarr/movies/:id` - Get specific movie details
### Emby
- `GET /api/emby/sessions` - Get active sessions
- `GET /api/emby/users` - Get all users
- `GET /api/emby/users/:id` - Get specific user details
- `GET /api/emby/session/:sessionId/user` - Get user from session
### Authentication
- `POST /api/auth/login` - Login with Emby credentials
- `POST /api/auth/logout` - Logout and clear session
### Dashboard
- `GET /api/dashboard/user-downloads/:sessionId` - Get downloads for current user
- `GET /api/dashboard/user-summary` - Get download summary for all users
- `GET /api/dashboard/downloads` - Get all downloads for authenticated user
## Production Build
### Service APIs (proxy to your services)
- `GET /api/sabnzbd/*` - SABnzbd API proxy
- `GET /api/qbittorrent/*` - qBittorrent API proxy
- `GET /api/sonarr/*` - Sonarr API proxy
- `GET /api/radarr/*` - Radarr API proxy
- `GET /api/emby/*` - Emby API proxy
Build the frontend for production:
## Logging Levels
```bash
npm run client:build
```
Set `LOG_LEVEL` in your `.env`:
- `debug` - Verbose logging for troubleshooting
- `info` - Standard operational logging (default)
- `warn` - Only warnings and errors
- `error` - Only errors
- `silent` - No logging
Start the production server:
```bash
npm run server:start
```
The frontend will be served from the backend server.
Logs are written to both console and `server.log` file.
## Troubleshooting
- **"Failed to fetch Emby sessions"**: Ensure Emby is running and the API key is correct
- **"No downloads found"**: Ensure your media is tagged with `user:username` format
- **Download not showing**: Check that the download name matches between SABnzbd and Sonarr/Radarr
**"No downloads showing"**
- Verify your media is tagged with your username in Sonarr/Radarr
- Check that LOG_LEVEL=debug shows matching attempts
- Ensure download names match between client and *arr apps
**"Can't connect to service"**
- Check URLs are accessible from the sofarr server
- Verify API keys and credentials
- Check CORS settings on your services
**"qBittorrent not showing"**
- Ensure username/password are correct
- Check qBittorrent Web UI is enabled
- Verify the URL includes the full path (e.g., `https://qb.example.com`)
## Development
```bash
# Start with auto-restart on changes
npm run dev
# Production start
npm start
```
## License
MIT
---
*sofarr: See what has downloaded "so far" from the comfort of your "sofa"*

2339
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.0.0"
"@vitejs/plugin-react": "^3.1.0",
"vite": "^4.5.0"
}
}

View File

@@ -155,6 +155,28 @@ body {
border-radius: 10px;
padding: 20px;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
gap: 20px;
align-items: flex-start;
}
.download-cover {
flex-shrink: 0;
width: 80px;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.download-cover img {
width: 100%;
height: auto;
display: block;
}
.download-info {
flex: 1;
min-width: 0;
}
.download-card:hover {

View File

@@ -118,6 +118,12 @@ function App() {
<div className="downloads-list">
{downloads.map((download, index) => (
<div key={index} className={`download-card ${download.type}`}>
{download.coverArt && (
<div className="download-cover">
<img src={download.coverArt} alt={download.movieName || download.seriesName || download.title} />
</div>
)}
<div className="download-info">
<div className="download-header">
<span className={`download-type ${download.type}`}>
{download.type === 'series' ? '📺 Series' : '🎬 Movie'}
@@ -163,6 +169,7 @@ function App() {
</div>
)}
</div>
</div>
</div>
))}
</div>

2859
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,24 @@
{
"name": "media-download-dashboard",
"name": "sofarr",
"version": "1.0.0",
"description": "Dashboard for tracking SABnzbd downloads matched to Sonarr/Radarr activity by user",
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
"main": "server/index.js",
"scripts": {
"dev": "concurrently \"npm run server:dev\" \"npm run client:dev\"",
"server:dev": "nodemon server/index.js",
"client:dev": "cd client && npm run dev",
"server:start": "node server/index.js",
"client:build": "cd client && npm run build",
"install:all": "npm install && cd client && npm install"
"dev": "nodemon server/index.js",
"start": "node server/index.js",
"install:all": "npm install"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"axios": "^1.6.0",
"node-cron": "^3.0.3"
"node-cron": "^3.0.3",
"cookie-parser": "^1.4.6"
},
"devDependencies": {
"nodemon": "^3.0.1",
"concurrently": "^8.2.2"
"nodemon": "^2.0.22",
"concurrently": "^7.6.0"
},
"keywords": [
"sabnzbd",

480
public/app.js Normal file
View File

@@ -0,0 +1,480 @@
let currentUser = null;
let downloads = [];
let refreshInterval = null;
let currentRefreshRate = 5000; // default 5 seconds
// Check authentication on load
document.addEventListener('DOMContentLoaded', () => {
checkAuthentication();
document.getElementById('login-form').addEventListener('submit', handleLogin);
document.getElementById('logout-btn').addEventListener('click', handleLogout);
document.getElementById('refresh-rate').addEventListener('change', handleRefreshRateChange);
});
function startAutoRefresh() {
if (refreshInterval) clearInterval(refreshInterval);
if (currentRefreshRate > 0) {
refreshInterval = setInterval(() => fetchUserDownloads(false), currentRefreshRate);
}
}
function handleRefreshRateChange(e) {
const rate = parseInt(e.target.value);
currentRefreshRate = rate;
startAutoRefresh();
}
function stopAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
async function checkAuthentication() {
try {
const response = await fetch('/api/auth/me');
const data = await response.json();
if (data.authenticated) {
currentUser = data.user;
showDashboard();
fetchUserDownloads(true);
startAutoRefresh();
} else {
showLogin();
}
} catch (err) {
console.error('Authentication check failed:', err);
showLogin();
}
}
async function handleLogin(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (data.success) {
currentUser = data.user;
showDashboard();
fetchUserDownloads(true);
startAutoRefresh();
} else {
showLoginError(data.error || 'Login failed');
}
} catch (err) {
showLoginError('Login failed. Please try again.');
console.error(err);
}
}
async function handleLogout() {
try {
stopAutoRefresh();
await fetch('/api/auth/logout', {
method: 'POST'
});
currentUser = null;
downloads = [];
showLogin();
} catch (err) {
console.error('Logout failed:', err);
}
}
function showLogin() {
document.getElementById('login-container').style.display = 'flex';
document.getElementById('dashboard-container').style.display = 'none';
hideLoginError();
}
function showDashboard() {
document.getElementById('login-container').style.display = 'none';
document.getElementById('dashboard-container').style.display = 'block';
document.getElementById('currentUser').textContent = currentUser.name || '-';
}
function showLoginError(message) {
const errorDiv = document.getElementById('login-error');
errorDiv.textContent = message;
errorDiv.style.display = 'block';
}
function hideLoginError() {
const errorDiv = document.getElementById('login-error');
errorDiv.style.display = 'none';
}
async function fetchUserDownloads(isInitialLoad = false) {
if (isInitialLoad) {
showLoading();
}
hideError();
try {
const response = await fetch('/api/dashboard/user-downloads');
const data = await response.json();
currentUser = data.user;
downloads = data.downloads;
// Debug: log first download to see what fields are present
if (downloads.length > 0) {
console.log('[Dashboard] Download data:', JSON.stringify(downloads[0]));
}
document.getElementById('currentUser').textContent = currentUser || '-';
renderDownloads();
} catch (err) {
showError('Failed to fetch downloads. Make sure all services are configured.');
console.error(err);
} finally {
if (isInitialLoad) {
hideLoading();
}
}
}
function renderDownloads() {
const downloadsList = document.getElementById('downloads-list');
const noDownloads = document.getElementById('no-downloads');
if (downloads.length === 0) {
noDownloads.style.display = 'block';
downloadsList.innerHTML = '';
return;
}
noDownloads.style.display = 'none';
// Get existing cards
const existingCards = new Map();
downloadsList.querySelectorAll('.download-card').forEach(card => {
existingCards.set(card.dataset.id, card);
});
// Track which downloads we've processed
const processedIds = new Set();
downloads.forEach(download => {
const id = download.title;
processedIds.add(id);
const existingCard = existingCards.get(id);
if (existingCard) {
// Update existing card
updateDownloadCard(existingCard, download);
} else {
// Create new card
const card = createDownloadCard(download);
downloadsList.appendChild(card);
}
});
// Remove cards for downloads that no longer exist
existingCards.forEach((card, id) => {
if (!processedIds.has(id)) {
card.remove();
}
});
}
function updateDownloadCard(card, download) {
// Update status
const statusEl = card.querySelector('.download-status');
if (statusEl && statusEl.textContent !== download.status) {
statusEl.textContent = download.status;
statusEl.className = `download-status ${download.status}`;
}
// Update progress bar and missing pieces
const progressContainer = card.querySelector('.progress-container');
if (progressContainer && download.progress !== undefined) {
const progressBar = progressContainer.querySelector('.progress-bar');
const progressText = progressContainer.querySelector('.progress-text');
const missingText = progressContainer.querySelector('.missing-text');
if (progressBar) {
const downloaded = progressBar.querySelector('.downloaded');
if (downloaded) {
downloaded.style.width = download.progress + '%';
}
}
if (progressText) {
progressText.textContent = download.progress + '%';
}
if (missingText) {
const totalMb = parseFloat(download.mb) || parseFloat(download.size);
const missingMb = parseFloat(download.mbmissing) || 0;
if (missingMb > 0 && totalMb > 0) {
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
} else {
missingText.textContent = '';
}
}
}
// Update speed
const speedEl = card.querySelector('.detail-item[data-label="Speed"] .detail-value');
if (speedEl && download.speed !== undefined) {
speedEl.textContent = download.speed;
}
// Update ETA
const etaEl = card.querySelector('.detail-item[data-label="ETA"] .detail-value');
if (etaEl && download.eta !== undefined) {
etaEl.textContent = download.eta;
}
// Update qBittorrent-specific fields
if (download.qbittorrent) {
const seedsEl = card.querySelector('.detail-item[data-label="Seeds"] .detail-value');
if (seedsEl && download.seeds !== undefined) {
seedsEl.textContent = download.seeds;
}
const peersEl = card.querySelector('.detail-item[data-label="Peers"] .detail-value');
if (peersEl && download.peers !== undefined) {
peersEl.textContent = download.peers;
}
const availabilityEl = card.querySelector('.detail-item[data-label="Availability"] .detail-value');
if (availabilityEl && download.availability !== undefined) {
availabilityEl.textContent = `${download.availability}%`;
}
}
}
function createDownloadCard(download) {
const card = document.createElement('div');
card.className = `download-card ${download.type}`;
card.dataset.id = download.title;
// Cover art
if (download.coverArt) {
const coverDiv = document.createElement('div');
coverDiv.className = 'download-cover';
const coverImg = document.createElement('img');
coverImg.src = download.coverArt;
coverImg.alt = download.movieName || download.seriesName || download.title;
coverImg.loading = 'lazy';
coverDiv.appendChild(coverImg);
card.appendChild(coverDiv);
}
// Info wrapper
const infoDiv = document.createElement('div');
infoDiv.className = 'download-info';
const header = document.createElement('div');
header.className = 'download-header';
const type = document.createElement('span');
type.className = `download-type ${download.type}`;
if (download.type === 'series') {
type.textContent = '📺 Series';
} else if (download.type === 'movie') {
type.textContent = '🎬 Movie';
} else if (download.type === 'torrent') {
const instName = download.instanceName ? ` (${download.instanceName})` : '';
type.textContent = `📥 Torrent${instName}`;
} else {
type.textContent = download.type;
}
const status = document.createElement('span');
status.className = `download-status ${download.status}`;
status.textContent = download.status;
header.appendChild(type);
header.appendChild(status);
const title = document.createElement('h3');
title.className = 'download-title';
title.textContent = download.title;
infoDiv.appendChild(header);
infoDiv.appendChild(title);
if (download.seriesName) {
const series = document.createElement('p');
series.className = 'download-series';
series.textContent = `Series: ${download.seriesName}`;
infoDiv.appendChild(series);
}
if (download.movieName) {
const movie = document.createElement('p');
movie.className = 'download-movie';
movie.textContent = `Movie: ${download.movieName}`;
infoDiv.appendChild(movie);
}
const details = document.createElement('div');
details.className = 'download-details';
const size = createDetailItem('Size', formatSize(download.size));
details.appendChild(size);
if (download.progress !== undefined) {
const progressItem = document.createElement('div');
progressItem.className = 'detail-item progress-item';
progressItem.dataset.label = 'Progress';
const labelSpan = document.createElement('span');
labelSpan.className = 'detail-label';
labelSpan.textContent = 'Progress';
const valueDiv = document.createElement('div');
valueDiv.className = 'progress-container';
// Progress bar with segments
const totalMb = parseFloat(download.mb) || parseFloat(download.size);
const missingMb = parseFloat(download.mbmissing) || 0;
const downloadedMb = totalMb - missingMb;
const progressPercent = parseFloat(download.progress) || 0;
const missingPercent = totalMb > 0 ? (missingMb / totalMb) * 100 : 0;
const progressBar = document.createElement('div');
progressBar.className = 'progress-bar';
// Downloaded portion (green)
if (progressPercent > 0) {
const downloaded = document.createElement('div');
downloaded.className = 'progress-segment downloaded';
downloaded.style.width = progressPercent + '%';
progressBar.appendChild(downloaded);
}
valueDiv.appendChild(progressBar);
// Text showing percentage
const progressText = document.createElement('span');
progressText.className = 'progress-text';
progressText.textContent = download.progress + '%';
valueDiv.appendChild(progressText);
// Missing pieces text
if (missingMb > 0 && totalMb > 0) {
const missingText = document.createElement('span');
missingText.className = 'missing-text';
missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`;
valueDiv.appendChild(missingText);
}
progressItem.appendChild(labelSpan);
progressItem.appendChild(valueDiv);
details.appendChild(progressItem);
}
if (download.speed) {
const speed = createDetailItem('Speed', download.speed);
details.appendChild(speed);
}
if (download.eta) {
const eta = createDetailItem('ETA', download.eta);
details.appendChild(eta);
}
// qBittorrent-specific fields
if (download.qbittorrent) {
if (download.seeds !== undefined) {
const seeds = createDetailItem('Seeds', download.seeds);
details.appendChild(seeds);
}
if (download.peers !== undefined) {
const peers = createDetailItem('Peers', download.peers);
details.appendChild(peers);
}
if (download.availability !== undefined) {
const availability = createDetailItem('Availability', `${download.availability}%`);
details.appendChild(availability);
}
}
if (download.completedAt) {
const completed = createDetailItem('Completed', formatDate(download.completedAt));
details.appendChild(completed);
}
infoDiv.appendChild(details);
card.appendChild(infoDiv);
return card;
}
function createDetailItem(label, value) {
const item = document.createElement('div');
item.className = 'detail-item';
item.dataset.label = label;
const labelSpan = document.createElement('span');
labelSpan.className = 'detail-label';
labelSpan.textContent = label;
const valueSpan = document.createElement('span');
valueSpan.className = 'detail-value';
valueSpan.textContent = value;
item.appendChild(labelSpan);
item.appendChild(valueSpan);
return item;
}
function formatSize(size) {
if (!size) return 'N/A';
// If already a formatted string (e.g., "21.5 GB"), return as-is
if (typeof size === 'string') {
return size;
}
// If it's a number (bytes), format it
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(size) / Math.log(1024));
return Math.round(size / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
function formatDate(dateString) {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString();
}
function showError(message) {
const errorDiv = document.getElementById('error-message');
errorDiv.textContent = message;
errorDiv.style.display = 'block';
}
function hideError() {
const errorDiv = document.getElementById('error-message');
errorDiv.style.display = 'none';
}
function showLoading() {
const loading = document.getElementById('loading');
loading.style.display = 'block';
}
function hideLoading() {
const loading = document.getElementById('loading');
loading.style.display = 'none';
}

73
public/index.html Normal file
View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>sofarr - Your Downloads Dashboard</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="app">
<!-- Login Form -->
<div id="login-container" class="login-container" style="display: none;">
<div class="login-box">
<h2>Login to Emby</h2>
<form id="login-form">
<div class="form-group">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="login-btn">Login</button>
</form>
<div id="login-error" class="error-message" style="display: none;"></div>
</div>
</div>
<!-- Dashboard -->
<div id="dashboard-container" class="dashboard-container" style="display: none;">
<header class="app-header">
<h1>sofarr</h1>
<div class="header-controls">
<div class="refresh-control">
<label for="refresh-rate">Refresh:</label>
<select id="refresh-rate">
<option value="1000">1s</option>
<option value="5000" selected>5s</option>
<option value="10000">10s</option>
<option value="0">Off</option>
</select>
</div>
<div class="user-info">
<span class="user-label">Current User:</span>
<span class="user-name" id="currentUser">-</span>
<button id="logout-btn" class="logout-btn">Logout</button>
</div>
</div>
</header>
<div id="error-message" class="error-message" style="display: none;"></div>
<div id="loading" class="loading" style="display: none;">Loading downloads...</div>
<div class="downloads-container">
<h2>Your Downloads</h2>
<div id="no-downloads" class="no-downloads" style="display: none;">
<p>No downloads found for your user.</p>
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
</div>
<div id="downloads-list" class="downloads-list"></div>
</div>
<footer class="app-footer">
<p>Ensure your media is tagged with your username in Sonarr/Radarr to match downloads to users.</p>
</footer>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

412
public/style.css Normal file
View File

@@ -0,0 +1,412 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.app {
min-height: 100vh;
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.app-header {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 20px;
}
.app-header h1 {
color: #333;
font-size: 2rem;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
background: #f0f0f0;
padding: 10px 20px;
border-radius: 20px;
}
.logout-btn {
padding: 5px 15px;
background: #f44336;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.3s;
}
.logout-btn:hover {
background: #d32f2f;
}
.user-label {
color: #666;
font-weight: 500;
}
.user-name {
color: #667eea;
font-weight: bold;
font-size: 1.1rem;
}
.error-message {
background: #fee;
color: #c33;
padding: 15px;
border-radius: 10px;
margin-bottom: 20px;
border-left: 4px solid #c33;
}
.loading {
text-align: center;
padding: 40px;
color: white;
font-size: 1.2rem;
}
.downloads-container {
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.downloads-container h2 {
color: #333;
margin-bottom: 20px;
font-size: 1.5rem;
}
.no-downloads {
text-align: center;
padding: 40px;
color: #666;
}
.no-downloads p {
margin: 10px 0;
}
.downloads-list {
display: grid;
gap: 20px;
}
.download-card {
border: 2px solid #e0e0e0;
border-radius: 10px;
padding: 20px;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
gap: 20px;
align-items: flex-start;
}
.download-cover {
flex-shrink: 0;
width: 80px;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.download-cover img {
width: 100%;
height: auto;
display: block;
}
.download-info {
flex: 1;
min-width: 0;
}
.download-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
}
.download-card.series {
border-left: 4px solid #667eea;
}
.download-card.movie {
border-left: 4px solid #f093fb;
}
.download-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.download-type {
padding: 5px 15px;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 500;
}
.download-type.series {
background: #e8eaf6;
color: #667eea;
}
.download-type.movie {
background: #fce4ec;
color: #f093fb;
}
.download-type.torrent {
background: #e0f2f1;
color: #26a69a;
}
.download-status {
padding: 5px 15px;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 500;
text-transform: capitalize;
}
.download-status.downloading {
background: #e8f5e9;
color: #4caf50;
}
.download-status.completed {
background: #e3f2fd;
color: #2196f3;
}
.download-status.failed {
background: #ffebee;
color: #f44336;
}
.download-title {
color: #333;
margin-bottom: 10px;
font-size: 1.2rem;
}
.download-series,
.download-movie {
color: #666;
margin-bottom: 15px;
font-style: italic;
}
.download-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
padding-top: 15px;
border-top: 1px solid #e0e0e0;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 5px;
}
.detail-label {
color: #999;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.detail-value {
color: #333;
font-weight: 500;
}
.app-footer {
margin-top: 20px;
text-align: center;
color: white;
font-size: 0.9rem;
}
.app-footer p {
opacity: 0.9;
}
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.login-box {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.login-box h2 {
color: #333;
margin-bottom: 30px;
text-align: center;
font-size: 1.8rem;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
color: #333;
font-weight: 500;
margin-bottom: 8px;
}
.form-group input {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 5px;
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.login-btn {
width: 100%;
padding: 12px;
background: #667eea;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: background 0.3s;
}
.login-btn:hover {
background: #5568d3;
}
/* Progress bar with missing pieces */
.progress-item {
grid-column: 1 / -1;
}
.progress-container {
display: flex;
flex-direction: column;
gap: 5px;
}
.progress-bar {
width: 100%;
height: 20px;
background: #ffebee;
border-radius: 10px;
overflow: hidden;
position: relative;
border: 1px solid #ffcdd2;
}
.progress-segment {
height: 100%;
transition: width 0.3s ease;
}
.progress-segment.downloaded {
background: linear-gradient(90deg, #4caf50 0%, #66bb6a 100%);
float: left;
}
.progress-text {
font-weight: bold;
color: #333;
}
.missing-text {
color: #f44336;
font-size: 0.9rem;
font-weight: 500;
}
/* Refresh control styles */
.header-controls {
display: flex;
align-items: center;
gap: 20px;
}
.refresh-control {
display: flex;
align-items: center;
gap: 8px;
}
.refresh-control label {
color: #666;
font-size: 0.9rem;
}
.refresh-control select {
padding: 5px 10px;
border: 1px solid #ddd;
border-radius: 5px;
background: white;
cursor: pointer;
}
@media (max-width: 768px) {
.app-header {
flex-direction: column;
align-items: flex-start;
}
.controls {
flex-direction: column;
align-items: stretch;
}
.download-details {
grid-template-columns: 1fr;
}
}

View File

@@ -1,29 +1,83 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const cookieParser = require('cookie-parser');
const fs = require('fs');
require('dotenv').config();
// Setup logging with levels
// Levels: debug (0), info (1), warn (2), error (3), silent (4)
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
const currentLevel = LOG_LEVELS[(process.env.LOG_LEVEL || 'info').toLowerCase()] || 1;
const logFile = fs.createWriteStream(path.join(__dirname, '../server.log'), { flags: 'a' });
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
const originalConsoleDebug = console.debug;
function shouldLog(level) {
return level >= currentLevel;
}
console.debug = function(...args) {
if (!shouldLog(LOG_LEVELS.debug)) return;
const message = args.join(' ');
originalConsoleDebug.apply(console, args);
logFile.write(`[${new Date().toISOString()}] DEBUG: ${message}\n`);
};
console.log = function(...args) {
if (!shouldLog(LOG_LEVELS.info)) return;
const message = args.join(' ');
originalConsoleLog.apply(console, args);
logFile.write(`[${new Date().toISOString()}] ${message}\n`);
};
console.warn = function(...args) {
if (!shouldLog(LOG_LEVELS.warn)) return;
const message = args.join(' ');
originalConsoleWarn.apply(console, args);
logFile.write(`[${new Date().toISOString()}] WARN: ${message}\n`);
};
console.error = function(...args) {
if (!shouldLog(LOG_LEVELS.error)) return;
const message = args.join(' ');
originalConsoleError.apply(console, args);
logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`);
};
const sabnzbdRoutes = require('./routes/sabnzbd');
const sonarrRoutes = require('./routes/sonarr');
const radarrRoutes = require('./routes/radarr');
const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard');
const authRoutes = require('./routes/auth');
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(cookieParser());
app.use(express.json());
app.use(express.static(path.join(__dirname, '../public')));
app.use('/api/sabnzbd', sabnzbdRoutes);
app.use('/api/sonarr', sonarrRoutes);
app.use('/api/radarr', radarrRoutes);
app.use('/api/emby', embyRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/auth', authRoutes);
app.get('/', (req, res) => {
res.json({ message: 'Media Download Dashboard API' });
res.sendFile(path.join(__dirname, '../public/index.html'));
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`=================================`);
console.log(` sofarr - Your Downloads Dashboard`);
console.log(` Server running on port ${PORT}`);
console.log(` Log level: ${process.env.LOG_LEVEL || 'info'}`);
console.log(`=================================`);
});

94
server/routes/auth.js Normal file
View File

@@ -0,0 +1,94 @@
const express = require('express');
const axios = require('axios');
const router = express.Router();
const EMBY_URL = process.env.EMBY_URL;
const EMBY_API_KEY = process.env.EMBY_API_KEY;
// Authenticate user with Emby
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
console.log(`[Auth] Attempting login for user: ${username}`);
// Authenticate with Emby
const authResponse = await axios.post(`${EMBY_URL}/Users/authenticatebyname`, {
Username: username,
Pw: password
}, {
headers: {
'X-Emby-Authorization': `MediaBrowser Client="MediaDashboard", Device="Browser", DeviceId="dashboard-${Date.now()}", Version="1.0.0"`
}
});
const authData = authResponse.data;
console.log(`[Auth] Emby auth response:`, JSON.stringify(authData));
// Get user info using the access token
const userResponse = await axios.get(`${EMBY_URL}/Users/${authData.User.Id || authData.User.id}`, {
headers: {
'X-MediaBrowser-Token': authData.AccessToken
}
});
const user = userResponse.data;
console.log(`[Auth] User info:`, JSON.stringify(user));
console.log(`[Auth] Login successful for user: ${user.Name}`);
// Set authentication cookie
res.cookie('emby_user', JSON.stringify({
id: user.Id,
name: user.Name,
token: authData.AccessToken
}), {
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
});
res.json({
success: true,
user: {
id: user.Id,
name: user.Name
}
});
} catch (error) {
console.error(`[Auth] Login failed:`, error.message);
res.status(401).json({
success: false,
error: 'Invalid username or password'
});
}
});
// Get current authenticated user
router.get('/me', (req, res) => {
try {
const userCookie = req.cookies.emby_user;
if (!userCookie) {
return res.json({ authenticated: false });
}
const user = JSON.parse(userCookie);
res.json({
authenticated: true,
user: {
id: user.id,
name: user.name
}
});
} catch (error) {
console.error(`[Auth] Error getting current user:`, error.message);
res.json({ authenticated: false });
}
});
// Logout
router.post('/logout', (req, res) => {
res.clearCookie('emby_user');
res.json({ success: true });
});
module.exports = router;

View File

@@ -2,131 +2,355 @@ const express = require('express');
const axios = require('axios');
const router = express.Router();
const SABNZBD_URL = process.env.SABNZBD_URL;
const SABNZBD_API_KEY = process.env.SABNZBD_API_KEY;
const SONARR_URL = process.env.SONARR_URL;
const SONARR_API_KEY = process.env.SONARR_API_KEY;
const RADARR_URL = process.env.RADARR_URL;
const RADARR_API_KEY = process.env.RADARR_API_KEY;
const { getTorrents, mapTorrentToDownload } = require('../utils/qbittorrent');
const {
getSABnzbdInstances,
getSonarrInstances,
getRadarrInstances
} = require('../utils/config');
const EMBY_URL = process.env.EMBY_URL;
const EMBY_API_KEY = process.env.EMBY_API_KEY;
// Helper function to extract user tag from series/movie
function extractUserTag(tags) {
if (!tags) return null;
const userTag = tags.find(tag => tag.label && tag.label.startsWith('user:'));
return userTag ? userTag.label.replace('user:', '') : null;
// Helper function to extract poster/cover art URL from a movie or series object
function getCoverArt(item) {
if (!item || !item.images) return null;
const poster = item.images.find(img => img.coverType === 'poster');
if (poster) return poster.remoteUrl || poster.url || null;
// Fallback to fanart if no poster
const fanart = item.images.find(img => img.coverType === 'fanart');
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
}
// Get user downloads for current session
router.get('/user-downloads/:sessionId', async (req, res) => {
// Helper function to extract user tag from series/movie
// For Radarr: tags is array of IDs, tagMap is id -> label mapping
// For Sonarr: tags is array of objects with label property
function extractUserTag(tags, tagMap) {
if (!tags || tags.length === 0) return null;
// If tagMap provided (Radarr), look up label by ID
if (tagMap) {
for (const tagId of tags) {
const label = tagMap.get(tagId);
if (label) return label;
}
return null;
}
// Sonarr style - tags are objects with label
const userTag = tags.find(tag => tag && tag.label);
return userTag ? userTag.label : null;
}
// Get user downloads for authenticated user
router.get('/user-downloads', async (req, res) => {
try {
// Get current user from Emby session
const sessionResponse = await axios.get(`${EMBY_URL}/Sessions`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
const session = sessionResponse.data.find(s => s.Id === req.params.sessionId);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
// Get authenticated user from cookie
const userCookie = req.cookies.emby_user;
if (!userCookie) {
return res.status(401).json({ error: 'Not authenticated' });
}
const userResponse = await axios.get(`${EMBY_URL}/Users/${session.UserId}`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
const currentUser = userResponse.data;
const username = currentUser.Name.toLowerCase();
const user = JSON.parse(userCookie);
const username = user.name.toLowerCase();
console.log(`[Dashboard] Fetching downloads for authenticated user: ${user.name} (${username})`);
// Get SABnzbd queue and history
const [sabnzbdQueue, sabnzbdHistory, sonarrQueue, sonarrHistory, radarrQueue, radarrHistory, sonarrSeries, radarrMovies] = await Promise.all([
axios.get(`${SABNZBD_URL}/api`, {
params: { mode: 'queue', apikey: SABNZBD_API_KEY, output: 'json' }
}),
axios.get(`${SABNZBD_URL}/api`, {
params: { mode: 'history', apikey: SABNZBD_API_KEY, output: 'json', limit: 100 }
}),
axios.get(`${SONARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': SONARR_API_KEY }
}).catch(() => ({ data: { records: [] } })),
axios.get(`${SONARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': SONARR_API_KEY },
params: { pageSize: 100 }
}).catch(() => ({ data: { records: [] } })),
axios.get(`${RADARR_URL}/api/v3/queue`, {
headers: { 'X-Api-Key': RADARR_API_KEY }
}).catch(() => ({ data: { records: [] } })),
axios.get(`${RADARR_URL}/api/v3/history`, {
headers: { 'X-Api-Key': RADARR_API_KEY },
params: { pageSize: 100 }
}).catch(() => ({ data: { records: [] } })),
axios.get(`${SONARR_URL}/api/v3/series`, {
headers: { 'X-Api-Key': SONARR_API_KEY }
}).catch(() => ({ data: [] })),
axios.get(`${RADARR_URL}/api/v3/movie`, {
headers: { 'X-Api-Key': RADARR_API_KEY }
}).catch(() => ({ data: [] }))
// Get all service instances
const sabInstances = getSABnzbdInstances();
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
console.log(`[Dashboard] Fetching data from all services...`);
console.log(`[Dashboard] SABnzbd instances: ${sabInstances.length}`);
console.log(`[Dashboard] Sonarr instances: ${sonarrInstances.length}`);
console.log(`[Dashboard] Radarr instances: ${radarrInstances.length}`);
// Fetch from all SABnzbd instances
const sabQueuePromises = sabInstances.map(inst =>
axios.get(`${inst.url}/api`, {
params: { mode: 'queue', apikey: inst.apiKey, output: 'json' }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Dashboard] SABnzbd ${inst.id} queue error:`, err.message);
return { instance: inst.id, data: { queue: { slots: [] } } };
})
);
const sabHistoryPromises = sabInstances.map(inst =>
axios.get(`${inst.url}/api`, {
params: { mode: 'history', apikey: inst.apiKey, output: 'json', limit: 100 }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Dashboard] SABnzbd ${inst.id} history error:`, err.message);
return { instance: inst.id, data: { history: { slots: [] } } };
})
);
// Fetch from all Sonarr instances
const sonarrTagsPromises = sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/tag`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Dashboard] Sonarr ${inst.id} tags error:`, err.message);
return { instance: inst.id, data: [] };
})
);
const sonarrQueuePromises = sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/queue`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { includeSeries: true, includeEpisode: true }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Dashboard] Sonarr ${inst.id} queue error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
);
const sonarrHistoryPromises = sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/history`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { pageSize: 100, includeSeries: true, includeEpisode: true }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Dashboard] Sonarr ${inst.id} history error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
);
const sonarrSeriesPromises = sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/series`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Dashboard] Sonarr ${inst.id} series error:`, err.message);
return { instance: inst.id, data: [] };
})
);
// Fetch from all Radarr instances
const radarrQueuePromises = radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/queue`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { includeMovie: true }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Dashboard] Radarr ${inst.id} queue error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
);
const radarrHistoryPromises = radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/history`, {
headers: { 'X-Api-Key': inst.apiKey },
params: { pageSize: 100, includeMovie: true }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Dashboard] Radarr ${inst.id} history error:`, err.message);
return { instance: inst.id, data: { records: [] } };
})
);
const radarrMoviesPromises = radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/movie`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Dashboard] Radarr ${inst.id} movies error:`, err.message);
return { instance: inst.id, data: [] };
})
);
const radarrTagsPromises = radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/tag`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(res => ({ instance: inst.id, data: res.data })).catch(err => {
console.error(`[Dashboard] Radarr ${inst.id} tags error:`, err.message);
return { instance: inst.id, data: [] };
})
);
// Execute all requests
const [
sabQueues, sabHistories, sonarrTagsResults, sonarrQueues, sonarrHistories, sonarrSeriesResults,
radarrQueues, radarrHistories, radarrMoviesResults, radarrTagsResults,
qbittorrentTorrents
] = await Promise.all([
Promise.all(sabQueuePromises),
Promise.all(sabHistoryPromises),
Promise.all(sonarrTagsPromises),
Promise.all(sonarrQueuePromises),
Promise.all(sonarrHistoryPromises),
Promise.all(sonarrSeriesPromises),
Promise.all(radarrQueuePromises),
Promise.all(radarrHistoryPromises),
Promise.all(radarrMoviesPromises),
Promise.all(radarrTagsPromises),
getTorrents().catch(err => {
console.error(`[Dashboard] qBittorrent error:`, err.message);
return [];
})
]);
// Aggregate data from all instances
const firstSabQueue = sabQueues[0] && sabQueues[0].data && sabQueues[0].data.queue;
const sabnzbdQueue = {
data: {
queue: {
slots: sabQueues.flatMap(q => (q.data.queue && q.data.queue.slots) || []),
status: firstSabQueue && firstSabQueue.status,
speed: firstSabQueue && firstSabQueue.speed,
kbpersec: firstSabQueue && firstSabQueue.kbpersec
}
}
};
const sabnzbdHistory = {
data: {
history: {
slots: sabHistories.flatMap(h => (h.data.history && h.data.history.slots) || [])
}
}
};
const sonarrQueue = {
data: {
records: sonarrQueues.flatMap(q => q.data.records || [])
}
};
const sonarrHistory = {
data: {
records: sonarrHistories.flatMap(h => h.data.records || [])
}
};
const sonarrSeries = {
data: sonarrSeriesResults.flatMap(s => s.data || [])
};
const radarrQueue = {
data: {
records: radarrQueues.flatMap(q => q.data.records || [])
}
};
const radarrHistory = {
data: {
records: radarrHistories.flatMap(h => h.data.records || [])
}
};
const radarrMovies = {
data: radarrMoviesResults.flatMap(m => m.data || [])
};
const radarrTags = {
data: radarrTagsResults.flatMap(t => t.data || [])
};
console.log(`[Dashboard] Data fetched successfully`);
console.log(`[Dashboard] Sonarr series: ${sonarrSeries.data.length}`);
console.log(`[Dashboard] Radarr movies: ${radarrMovies.data.length}`);
console.log(`[Dashboard] Radarr queue records: ${radarrQueue.data.records.length}`);
console.log(`[Dashboard] Radarr history records: ${radarrHistory.data.records.length}`);
console.log(`[Dashboard] SABnzbd queue slots: ${sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.slots.length : 0}`);
console.log(`[Dashboard] SABnzbd history slots: ${sabnzbdHistory.data.history ? sabnzbdHistory.data.history.slots.length : 0}`);
console.log(`[Dashboard] qBittorrent torrents: ${qbittorrentTorrents.length}`);
console.log(`[Dashboard] Radarr queue:`, JSON.stringify(radarrQueue.data.records));
console.log(`[Dashboard] Radarr history:`, JSON.stringify(radarrHistory.data.records));
// Create maps for quick lookup
const seriesMap = new Map(sonarrSeries.data.map(s => [s.tvdbId || s.id, s]));
const moviesMap = new Map(radarrMovies.data.map(m => [m.tmdbId || m.id, m]));
const seriesMap = new Map(sonarrSeries.data.map(s => [s.id, s]));
const moviesMap = new Map(radarrMovies.data.map(m => [m.id, m]));
// Create tag maps (id -> label)
const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label]));
console.log(`[Dashboard] Radarr tags:`, JSON.stringify(radarrTags.data));
console.log(`[Dashboard] Movies map keys:`, Array.from(moviesMap.keys()).slice(0, 10));
console.log(`[Dashboard] Looking for movieId: 2962`);
console.log(`[Dashboard] Movie 2962:`, JSON.stringify(moviesMap.get(2962)));
console.log(`[Dashboard] Sample movie structure:`, JSON.stringify(radarrMovies.data[0]));
// Match SABnzbd downloads to Sonarr/Radarr activity
const userDownloads = [];
// Process SABnzbd queue
const queueStatus = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.status : null;
const queueSpeed = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.speed : null;
const queueKbpersec = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.kbpersec : null;
console.log(`[Dashboard] Queue status: ${queueStatus}, speed: ${queueSpeed}, kbpersec: ${queueKbpersec}`);
// Helper to determine status and speed
function getSlotStatusAndSpeed(slot) {
// If whole queue is paused, everything is paused with 0 speed
if (queueStatus === 'Paused') {
return { status: 'Paused', speed: '0' };
}
// Use slot's actual status and queue speed
return {
status: slot.status || 'Unknown',
speed: queueSpeed || queueKbpersec || '0'
};
}
if (sabnzbdQueue.data.queue && sabnzbdQueue.data.queue.slots) {
for (const slot of sabnznzbdQueue.data.queue.slots) {
const nzbName = slot.nzbname.toLowerCase();
// Try to match with Sonarr
const sonarrMatch = sonarrQueue.data.records.find(r =>
r.title.toLowerCase().includes(nzbName) || nzbName.includes(r.title.toLowerCase())
);
if (sonarrMatch && sonarrMatch.seriesId) {
const series = seriesMap.get(sonarrMatch.seriesId);
if (series) {
const userTag = extractUserTag(series.tags);
if (userTag && userTag.toLowerCase() === username) {
userDownloads.push({
type: 'series',
title: slot.nzbname,
status: 'downloading',
progress: slot.percentage,
size: slot.size,
speed: slot.speed,
eta: slot.eta,
seriesName: series.title,
episodeInfo: sonarrMatch
});
for (const slot of sabnzbdQueue.data.queue.slots) {
try {
const nzbName = slot.filename || slot.nzbname;
if (!nzbName) {
console.log(`[Dashboard] Skipping slot with no filename/nzbname`);
continue;
}
const slotState = getSlotStatusAndSpeed(slot);
console.log(`[Dashboard] Slot ${nzbName}: status=${slotState.status}, speed=${slotState.speed}`);
const nzbNameLower = nzbName.toLowerCase();
// Try to match with Sonarr
const sonarrMatch = sonarrQueue.data.records.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
});
if (sonarrMatch && sonarrMatch.seriesId) {
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) {
const userTag = extractUserTag(series.tags, sonarrTagMap);
if (userTag && userTag.toLowerCase() === username) {
userDownloads.push({
type: 'series',
title: nzbName,
coverArt: getCoverArt(series),
status: slotState.status,
progress: slot.percentage,
mb: slot.mb,
mbmissing: slot.mbmissing,
size: slot.size,
speed: slotState.speed,
eta: slot.timeleft,
seriesName: series.title,
episodeInfo: sonarrMatch
});
}
}
}
}
// Try to match with Radarr
const radarrMatch = radarrQueue.data.records.find(r =>
r.title.toLowerCase().includes(nzbName) || nzbName.includes(r.title.toLowerCase())
);
if (radarrMatch && radarrMatch.movieId) {
const movie = moviesMap.get(radarrMatch.movieId);
if (movie) {
const userTag = extractUserTag(movie.tags);
if (userTag && userTag.toLowerCase() === username) {
userDownloads.push({
type: 'movie',
title: slot.nzbname,
status: 'downloading',
progress: slot.percentage,
size: slot.size,
speed: slot.speed,
eta: slot.eta,
movieName: movie.title,
movieInfo: radarrMatch
});
// Try to match with Radarr
const radarrMatch = radarrQueue.data.records.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
});
if (radarrMatch && radarrMatch.movieId) {
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
if (movie) {
const userTag = extractUserTag(movie.tags, radarrTagMap);
if (userTag && userTag.toLowerCase() === username) {
userDownloads.push({
type: 'movie',
title: nzbName,
coverArt: getCoverArt(movie),
status: slotState.status,
progress: slot.percentage,
mb: slot.mb,
mbmissing: slot.mbmissing,
size: slot.size,
speed: slotState.speed,
eta: slot.timeleft,
movieName: movie.title,
movieInfo: radarrMatch
});
}
}
}
} catch (err) {
console.error(`[Dashboard] Error processing slot:`, err.message);
console.error(`[Dashboard] Slot data:`, JSON.stringify(slot));
}
}
}
@@ -134,61 +358,214 @@ router.get('/user-downloads/:sessionId', async (req, res) => {
// Process SABnzbd history
if (sabnzbdHistory.data.history && sabnzbdHistory.data.history.slots) {
for (const slot of sabnzbdHistory.data.history.slots) {
const nzbName = slot.nzbname.toLowerCase();
// Try to match with Sonarr history
const sonarrMatch = sonarrHistory.data.records.find(r =>
r.title.toLowerCase().includes(nzbName) || nzbName.includes(r.title.toLowerCase())
);
if (sonarrMatch && sonarrMatch.seriesId) {
const series = seriesMap.get(sonarrMatch.seriesId);
if (series) {
const userTag = extractUserTag(series.tags);
if (userTag && userTag.toLowerCase() === username) {
userDownloads.push({
type: 'series',
title: slot.nzbname,
status: slot.status,
size: slot.size,
completedAt: slot.completed_time,
seriesName: series.title,
episodeInfo: sonarrMatch
});
try {
const nzbName = slot.name || slot.nzb_name || slot.nzbname;
if (!nzbName) {
console.log(`[Dashboard] Skipping history slot with no name/nzb_name/nzbname`);
continue;
}
const nzbNameLower = nzbName.toLowerCase();
// Try to match with Sonarr history
const sonarrMatch = sonarrHistory.data.records.find(r => {
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
});
if (sonarrMatch && sonarrMatch.seriesId) {
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) {
const userTag = extractUserTag(series.tags, sonarrTagMap);
if (userTag && userTag.toLowerCase() === username) {
userDownloads.push({
type: 'series',
title: nzbName,
coverArt: getCoverArt(series),
status: slot.status,
size: slot.size,
completedAt: slot.completed_time,
seriesName: series.title,
episodeInfo: sonarrMatch
});
}
}
}
}
// Try to match with Radarr history
const radarrMatch = radarrHistory.data.records.find(r =>
r.title.toLowerCase().includes(nzbName) || nzbName.includes(r.title.toLowerCase())
);
if (radarrMatch && radarrMatch.movieId) {
const movie = moviesMap.get(radarrMatch.movieId);
if (movie) {
const userTag = extractUserTag(movie.tags);
if (userTag && userTag.toLowerCase() === username) {
userDownloads.push({
type: 'movie',
title: slot.nzbname,
status: slot.status,
size: slot.size,
completedAt: slot.completed_time,
movieName: movie.title,
movieInfo: radarrMatch
});
// Try to match with Radarr history
const radarrMatch = radarrHistory.data.records.find(r => {
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
});
if (radarrMatch && radarrMatch.movieId) {
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
if (movie) {
const userTag = extractUserTag(movie.tags, radarrTagMap);
if (userTag && userTag.toLowerCase() === username) {
userDownloads.push({
type: 'movie',
title: nzbName,
coverArt: getCoverArt(movie),
status: slot.status,
size: slot.size,
completedAt: slot.completed_time,
movieName: movie.title,
movieInfo: radarrMatch
});
}
}
}
} catch (err) {
console.error(`[Dashboard] Error processing history slot:`, err.message);
console.error(`[Dashboard] History slot data:`, JSON.stringify(slot));
}
}
}
// Debug: show what queue records look like and which movies/series are tagged for this user
console.log(`[Dashboard] Sonarr queue titles:`, sonarrQueue.data.records.map(r => ({ title: r.title, seriesId: r.seriesId })));
console.log(`[Dashboard] Radarr queue titles:`, radarrQueue.data.records.map(r => ({ title: r.title, movieId: r.movieId })));
console.log(`[Dashboard] Sonarr history titles:`, sonarrHistory.data.records.slice(0, 10).map(r => ({ title: r.title, seriesId: r.seriesId })));
console.log(`[Dashboard] Radarr history titles:`, radarrHistory.data.records.slice(0, 10).map(r => ({ title: r.title, movieId: r.movieId })));
console.log(`[Dashboard] Sonarr tag map:`, Array.from(sonarrTagMap.entries()));
console.log(`[Dashboard] Radarr tag map:`, Array.from(radarrTagMap.entries()));
// Show movies/series tagged for this user
const userMovies = radarrMovies.data.filter(m => {
const tag = extractUserTag(m.tags, radarrTagMap);
return tag && tag.toLowerCase() === username;
});
const userSeries = sonarrSeries.data.filter(s => {
const tag = extractUserTag(s.tags, sonarrTagMap);
return tag && tag.toLowerCase() === username;
});
console.log(`[Dashboard] Movies tagged for ${username}:`, userMovies.map(m => m.title));
console.log(`[Dashboard] Series tagged for ${username}:`, userSeries.map(s => s.title));
// Process qBittorrent torrents - match to user-tagged Sonarr/Radarr activity
console.log(`[Dashboard] Processing ${qbittorrentTorrents.length} qBittorrent torrents for user ${username}`);
for (const torrent of qbittorrentTorrents) {
try {
const torrentName = torrent.name || '';
const torrentNameLower = torrentName.toLowerCase();
if (!torrentName) continue;
console.log(`[Dashboard] Checking torrent "${torrentName}"`);
// Try to match with Sonarr queue (user-tagged series)
const sonarrMatch = sonarrQueue.data.records.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
});
if (sonarrMatch && sonarrMatch.seriesId) {
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
if (series) {
const userTag = extractUserTag(series.tags, sonarrTagMap);
if (userTag && userTag.toLowerCase() === username) {
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr series "${series.title}"`);
const download = mapTorrentToDownload(torrent);
download.type = 'series';
download.coverArt = getCoverArt(series);
download.seriesName = series.title;
download.episodeInfo = sonarrMatch;
download.userTag = userTag;
userDownloads.push(download);
continue; // Skip to next torrent
}
}
}
// Try to match with Radarr queue (user-tagged movies)
const radarrMatch = radarrQueue.data.records.find(r => {
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
});
if (radarrMatch && radarrMatch.movieId) {
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
if (movie) {
const userTag = extractUserTag(movie.tags, radarrTagMap);
if (userTag && userTag.toLowerCase() === username) {
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr movie "${movie.title}"`);
const download = mapTorrentToDownload(torrent);
download.type = 'movie';
download.coverArt = getCoverArt(movie);
download.movieName = movie.title;
download.movieInfo = radarrMatch;
download.userTag = userTag;
userDownloads.push(download);
continue; // Skip to next torrent
}
}
}
// Try to match with Sonarr history
const sonarrHistoryMatch = sonarrHistory.data.records.find(r => {
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
});
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
if (series) {
const userTag = extractUserTag(series.tags, sonarrTagMap);
if (userTag && userTag.toLowerCase() === username) {
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr history "${series.title}"`);
const download = mapTorrentToDownload(torrent);
download.type = 'series';
download.coverArt = getCoverArt(series);
download.seriesName = series.title;
download.episodeInfo = sonarrHistoryMatch;
download.userTag = userTag;
userDownloads.push(download);
continue;
}
}
}
// Try to match with Radarr history
const radarrHistoryMatch = radarrHistory.data.records.find(r => {
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
});
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
if (movie) {
const userTag = extractUserTag(movie.tags, radarrTagMap);
if (userTag && userTag.toLowerCase() === username) {
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr history "${movie.title}"`);
const download = mapTorrentToDownload(torrent);
download.type = 'movie';
download.coverArt = getCoverArt(movie);
download.movieName = movie.title;
download.movieInfo = radarrHistoryMatch;
download.userTag = userTag;
userDownloads.push(download);
continue;
}
}
}
} catch (err) {
console.error(`[Dashboard] Error processing torrent:`, err.message);
console.error(`[Dashboard] Torrent data:`, JSON.stringify(torrent));
}
}
console.log(`[Dashboard] Found ${userDownloads.length} downloads for user ${username}`);
console.log(`[Dashboard] Sending ${userDownloads.length} downloads`);
if (userDownloads.length > 0) {
console.log(`[Dashboard] First download:`, JSON.stringify(userDownloads[0]));
}
res.json({
user: currentUser.Name,
user: user.name,
downloads: userDownloads
});
} catch (error) {
console.error(`[Dashboard] Error fetching user downloads:`, error.message);
console.error(`[Dashboard] Full error:`, error);
res.status(500).json({ error: 'Failed to fetch user downloads', details: error.message });
}
});
@@ -196,21 +573,43 @@ router.get('/user-downloads/:sessionId', async (req, res) => {
// Get all users with their download counts
router.get('/user-summary', async (req, res) => {
try {
const sonarrInstances = getSonarrInstances();
const radarrInstances = getRadarrInstances();
// Get all Emby users
const usersResponse = await axios.get(`${EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
});
// Get all series and movies with tags
const [sonarrSeries, radarrMovies] = await Promise.all([
axios.get(`${SONARR_URL}/api/v3/series`, {
headers: { 'X-Api-Key': SONARR_API_KEY }
}).catch(() => ({ data: [] })),
axios.get(`${RADARR_URL}/api/v3/movie`, {
headers: { 'X-Api-Key': RADARR_API_KEY }
}).catch(() => ({ data: [] }))
// Get all series, movies, and tags from all instances
const [sonarrSeriesResults, sonarrTagsResults, radarrMoviesResults, radarrTagsResults] = await Promise.all([
Promise.all(sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/series`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(r => r.data).catch(() => [])
)),
Promise.all(sonarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/tag`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(r => r.data).catch(() => [])
)),
Promise.all(radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/movie`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(r => r.data).catch(() => [])
)),
Promise.all(radarrInstances.map(inst =>
axios.get(`${inst.url}/api/v3/tag`, {
headers: { 'X-Api-Key': inst.apiKey }
}).then(r => r.data).catch(() => [])
))
]);
const allSeries = sonarrSeriesResults.flat();
const sonarrTagMap = new Map(sonarrTagsResults.flat().map(t => [t.id, t.label]));
const allMovies = radarrMoviesResults.flat();
const radarrTagMap = new Map(radarrTagsResults.flat().map(t => [t.id, t.label]));
// Count downloads per user
const userDownloads = {};
usersResponse.data.forEach(user => {
@@ -222,8 +621,8 @@ router.get('/user-summary', async (req, res) => {
});
// Process series tags
sonarrSeries.data.forEach(series => {
const userTag = extractUserTag(series.tags);
allSeries.forEach(series => {
const userTag = extractUserTag(series.tags, sonarrTagMap);
if (userTag) {
const username = userTag.toLowerCase();
if (userDownloads[username]) {
@@ -233,8 +632,8 @@ router.get('/user-summary', async (req, res) => {
});
// Process movie tags
radarrMovies.data.forEach(movie => {
const userTag = extractUserTag(movie.tags);
allMovies.forEach(movie => {
const userTag = extractUserTag(movie.tags, radarrTagMap);
if (userTag) {
const username = userTag.toLowerCase();
if (userDownloads[username]) {

78
server/utils/config.js Normal file
View File

@@ -0,0 +1,78 @@
const { logToFile } = require('./logger');
function parseInstances(envVar, legacyUrl, legacyKey, legacyUsername, legacyPassword) {
// Try to parse JSON array format first
if (envVar) {
try {
// Handle multi-line JSON by removing newlines and extra spaces
const cleaned = envVar.replace(/\s+/g, ' ').trim();
const instances = JSON.parse(cleaned);
if (Array.isArray(instances) && instances.length > 0) {
logToFile(`[Config] Parsed ${instances.length} instances from JSON array`);
return instances.map((inst, idx) => ({
...inst,
id: inst.name || `instance-${idx + 1}`
}));
}
} catch (err) {
logToFile(`[Config] Failed to parse JSON array: ${err.message}`);
}
}
// Fall back to legacy single-instance format
if (legacyUrl && legacyKey) {
logToFile(`[Config] Using legacy single-instance format`);
return [{
id: 'default',
name: 'Default',
url: legacyUrl,
apiKey: legacyKey,
username: legacyUsername,
password: legacyPassword
}];
}
return [];
}
function getSABnzbdInstances() {
return parseInstances(
process.env.SABNZBD_INSTANCES,
process.env.SABNZBD_URL,
process.env.SABNZBD_API_KEY
);
}
function getSonarrInstances() {
return parseInstances(
process.env.SONARR_INSTANCES,
process.env.SONARR_URL,
process.env.SONARR_API_KEY
);
}
function getRadarrInstances() {
return parseInstances(
process.env.RADARR_INSTANCES,
process.env.RADARR_URL,
process.env.RADARR_API_KEY
);
}
function getQbittorrentInstances() {
return parseInstances(
process.env.QBITTORRENT_INSTANCES,
process.env.QBITTORRENT_URL,
null, // no apiKey for qBittorrent
process.env.QBITTORRENT_USERNAME,
process.env.QBITTORRENT_PASSWORD
);
}
module.exports = {
getSABnzbdInstances,
getSonarrInstances,
getRadarrInstances,
getQbittorrentInstances,
parseInstances
};

12
server/utils/logger.js Normal file
View File

@@ -0,0 +1,12 @@
const fs = require('fs');
const path = require('path');
const logFile = fs.createWriteStream(path.join(__dirname, '../../server.log'), { flags: 'a' });
function logToFile(message) {
logFile.write(`[${new Date().toISOString()}] ${message}\n`);
}
module.exports = {
logToFile
};

212
server/utils/qbittorrent.js Normal file
View File

@@ -0,0 +1,212 @@
const axios = require('axios');
const { logToFile } = require('./logger');
const { getQbittorrentInstances } = require('./config');
class QBittorrentClient {
constructor(instance) {
this.id = instance.id;
this.name = instance.name;
this.url = instance.url;
this.username = instance.username;
this.password = instance.password;
this.authCookie = null;
}
async login() {
try {
logToFile(`[qBittorrent:${this.name}] Attempting login...`);
const response = await axios.post(`${this.url}/api/v2/auth/login`,
`username=${encodeURIComponent(this.username)}&password=${encodeURIComponent(this.password)}`,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400
}
);
if (response.headers['set-cookie']) {
this.authCookie = response.headers['set-cookie'][0];
logToFile(`[qBittorrent:${this.name}] Login successful`);
return true;
}
logToFile(`[qBittorrent:${this.name}] Login failed - no cookie`);
return false;
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Login error: ${error.message}`);
return false;
}
}
async makeRequest(endpoint, config = {}) {
const url = `${this.url}${endpoint}`;
if (!this.authCookie) {
const loggedIn = await this.login();
if (!loggedIn) {
throw new Error(`Failed to authenticate with ${this.name}`);
}
}
try {
const response = await axios.get(url, {
...config,
headers: {
...config.headers,
'Cookie': this.authCookie
}
});
return response;
} catch (error) {
// If unauthorized, try re-authenticating once
if (error.response && error.response.status === 403) {
logToFile(`[qBittorrent:${this.name}] Auth expired, re-authenticating...`);
this.authCookie = null;
const loggedIn = await this.login();
if (loggedIn) {
return axios.get(url, {
...config,
headers: {
...config.headers,
'Cookie': this.authCookie
}
});
}
}
throw error;
}
}
async getTorrents() {
try {
const response = await this.makeRequest('/api/v2/torrents/info');
logToFile(`[qBittorrent:${this.name}] Retrieved ${response.data.length} torrents`);
// Add instance info to each torrent
return response.data.map(torrent => ({
...torrent,
instanceId: this.id,
instanceName: this.name
}));
} catch (error) {
logToFile(`[qBittorrent:${this.name}] Error fetching torrents: ${error.message}`);
return [];
}
}
}
function getClients() {
const instances = getQbittorrentInstances();
if (instances.length === 0) {
logToFile('[qBittorrent] No instances configured');
return [];
}
logToFile(`[qBittorrent] Created ${instances.length} client(s)`);
return instances.map(inst => new QBittorrentClient(inst));
}
async function getAllTorrents() {
const clients = getClients();
if (clients.length === 0) {
return [];
}
const results = await Promise.all(
clients.map(client => client.getTorrents().catch(err => {
logToFile(`[qBittorrent] Error from ${client.name}: ${err.message}`);
return [];
}))
);
const allTorrents = results.flat();
logToFile(`[qBittorrent] Total torrents from all instances: ${allTorrents.length}`);
return allTorrents;
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatSpeed(bytesPerSecond) {
return formatBytes(bytesPerSecond) + '/s';
}
function formatEta(seconds) {
if (seconds < 0 || seconds === 8640000) return '∞'; // qBittorrent uses 8640000 for unknown
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function mapTorrentToDownload(torrent) {
const totalSize = torrent.size;
const downloadedSize = torrent.completed;
const progress = torrent.progress * 100;
// Map qBittorrent states to our status
const stateMap = {
'downloading': 'Downloading',
'stalledDL': 'Downloading',
'metaDL': 'Downloading',
'forcedDL': 'Downloading',
'allocating': 'Downloading',
'uploading': 'Seeding',
'stalledUP': 'Seeding',
'forcedUP': 'Seeding',
'queuedUP': 'Queued',
'queuedDL': 'Queued',
'checkingUP': 'Checking',
'checkingDL': 'Checking',
'checkingResumeData': 'Checking',
'moving': 'Moving',
'pausedUP': 'Paused',
'pausedDL': 'Paused',
'stoppedUP': 'Stopped',
'stoppedDL': 'Stopped',
'error': 'Error',
'missingFiles': 'Error',
'unknown': 'Unknown'
};
const status = stateMap[torrent.state] || torrent.state;
return {
type: 'torrent',
title: torrent.name,
instanceName: torrent.instanceName,
status: status,
progress: progress.toFixed(1),
size: formatBytes(totalSize),
rawSize: totalSize,
rawDownloaded: downloadedSize,
speed: formatSpeed(torrent.dlspeed),
rawSpeed: torrent.dlspeed,
eta: formatEta(torrent.eta),
rawEta: torrent.eta,
seeds: torrent.num_seeds,
peers: torrent.num_leechs,
availability: (torrent.availability * 100).toFixed(1),
hash: torrent.hash,
category: torrent.category,
tags: torrent.tags,
qbittorrent: true
};
}
module.exports = {
getTorrents: getAllTorrents,
getClients,
mapTorrentToDownload,
formatBytes,
formatSpeed,
formatEta
};