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:
30
.env.example
30
.env.example
@@ -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
77
.env.sample
Normal 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
309
README.md
@@ -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
2339
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
2859
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -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
480
public/app.js
Normal 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
73
public/index.html
Normal 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
412
public/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
94
server/routes/auth.js
Normal 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;
|
||||
@@ -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
78
server/utils/config.js
Normal 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
12
server/utils/logger.js
Normal 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
212
server/utils/qbittorrent.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user