Initial commit: Media Download Dashboard with SABnzbd, Sonarr, Radarr, and Emby integration
This commit is contained in:
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 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_URL=http://localhost:8096
|
||||||
|
EMBY_API_KEY=your_emby_api_key
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
136
README.md
Normal file
136
README.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Media Download Dashboard
|
||||||
|
|
||||||
|
A dashboard for tracking SABnzbd downloads matched to Sonarr/Radarr activity by user, with user identification via Emby server sessions.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
## 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`
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
- `GET /api/dashboard/user-downloads/:sessionId` - Get downloads for current user
|
||||||
|
- `GET /api/dashboard/user-summary` - Get download summary for all users
|
||||||
|
|
||||||
|
## Production Build
|
||||||
|
|
||||||
|
Build the frontend for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run client:build
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the production server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run server:start
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend will be served from the backend server.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
12
client/index.html
Normal file
12
client/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Media Download Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
22
client/package.json
Normal file
22
client/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "media-download-dashboard-client",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"axios": "^1.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.37",
|
||||||
|
"@types/react-dom": "^18.2.15",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
284
client/src/App.css
Normal file
284
client/src/App.css
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
* {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-label {
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
color: #667eea;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls label {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
180
client/src/App.jsx
Normal file
180
client/src/App.jsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [sessionId, setSessionId] = useState('');
|
||||||
|
const [currentUser, setCurrentUser] = useState(null);
|
||||||
|
const [downloads, setDownloads] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [sessions, setSessions] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSessions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSessions = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/emby/sessions');
|
||||||
|
setSessions(response.data);
|
||||||
|
|
||||||
|
// Auto-select first active session
|
||||||
|
const activeSession = response.data.find(s => s.NowPlayingItem || s.Active);
|
||||||
|
if (activeSession) {
|
||||||
|
setSessionId(activeSession.Id);
|
||||||
|
fetchUserDownloads(activeSession.Id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to fetch Emby sessions. Make sure Emby is running and configured.');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchUserDownloads = async (sessionId) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/dashboard/user-downloads/${sessionId}`);
|
||||||
|
setCurrentUser(response.data.user);
|
||||||
|
setDownloads(response.data.downloads);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to fetch downloads. Make sure all services are configured.');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSessionChange = (e) => {
|
||||||
|
const newSessionId = e.target.value;
|
||||||
|
setSessionId(newSessionId);
|
||||||
|
if (newSessionId) {
|
||||||
|
fetchUserDownloads(newSessionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSize = (bytes) => {
|
||||||
|
if (!bytes) return 'N/A';
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
return new Date(dateString).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="app-header">
|
||||||
|
<h1>Media Download Dashboard</h1>
|
||||||
|
{currentUser && (
|
||||||
|
<div className="user-info">
|
||||||
|
<span className="user-label">Current User:</span>
|
||||||
|
<span className="user-name">{currentUser}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="controls">
|
||||||
|
<label htmlFor="session-select">Select Emby Session:</label>
|
||||||
|
<select
|
||||||
|
id="session-select"
|
||||||
|
value={sessionId}
|
||||||
|
onChange={handleSessionChange}
|
||||||
|
className="session-select"
|
||||||
|
>
|
||||||
|
<option value="">-- Select Session --</option>
|
||||||
|
{sessions.map(session => (
|
||||||
|
<option key={session.Id} value={session.Id}>
|
||||||
|
{session.UserName} - {session.Client} {session.NowPlayingItem ? `(Playing: ${session.NowPlayingItem.Name})` : '(Idle)'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button onClick={fetchSessions} className="refresh-btn">Refresh Sessions</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="loading">Loading downloads...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="downloads-container">
|
||||||
|
<h2>Your Downloads</h2>
|
||||||
|
{downloads.length === 0 ? (
|
||||||
|
<div className="no-downloads">
|
||||||
|
<p>No downloads found for your user.</p>
|
||||||
|
<p>Make sure your shows and movies are tagged with "user:yourusername" in Sonarr/Radarr.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="downloads-list">
|
||||||
|
{downloads.map((download, index) => (
|
||||||
|
<div key={index} className={`download-card ${download.type}`}>
|
||||||
|
<div className="download-header">
|
||||||
|
<span className={`download-type ${download.type}`}>
|
||||||
|
{download.type === 'series' ? '📺 Series' : '🎬 Movie'}
|
||||||
|
</span>
|
||||||
|
<span className={`download-status ${download.status}`}>
|
||||||
|
{download.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="download-title">{download.title}</h3>
|
||||||
|
{download.seriesName && (
|
||||||
|
<p className="download-series">Series: {download.seriesName}</p>
|
||||||
|
)}
|
||||||
|
{download.movieName && (
|
||||||
|
<p className="download-movie">Movie: {download.movieName}</p>
|
||||||
|
)}
|
||||||
|
<div className="download-details">
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="detail-label">Size:</span>
|
||||||
|
<span className="detail-value">{formatSize(download.size)}</span>
|
||||||
|
</div>
|
||||||
|
{download.progress && (
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="detail-label">Progress:</span>
|
||||||
|
<span className="detail-value">{download.progress}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{download.speed && (
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="detail-label">Speed:</span>
|
||||||
|
<span className="detail-value">{download.speed}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{download.eta && (
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="detail-label">ETA:</span>
|
||||||
|
<span className="detail-value">{download.eta}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{download.completedAt && (
|
||||||
|
<div className="detail-item">
|
||||||
|
<span className="detail-label">Completed:</span>
|
||||||
|
<span className="detail-value">{formatDate(download.completedAt)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="app-footer">
|
||||||
|
<p>Ensure your media is tagged with "user:username" in Sonarr/Radarr to match downloads to users.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
10
client/src/main.jsx
Normal file
10
client/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
15
client/vite.config.js
Normal file
15
client/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
34
package.json
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "media-download-dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Dashboard for tracking SABnzbd downloads matched to Sonarr/Radarr activity by user",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"node-cron": "^3.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.1",
|
||||||
|
"concurrently": "^8.2.2"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"sabnzbd",
|
||||||
|
"sonarr",
|
||||||
|
"radarr",
|
||||||
|
"emby",
|
||||||
|
"dashboard"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
29
server/index.js
Normal file
29
server/index.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
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 app = express();
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
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.get('/', (req, res) => {
|
||||||
|
res.json({ message: 'Media Download Dashboard API' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
252
server/routes/dashboard.js
Normal file
252
server/routes/dashboard.js
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user downloads for current session
|
||||||
|
router.get('/user-downloads/:sessionId', 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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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: [] }))
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 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]));
|
||||||
|
|
||||||
|
// Match SABnzbd downloads to Sonarr/Radarr activity
|
||||||
|
const userDownloads = [];
|
||||||
|
|
||||||
|
// Process SABnzbd queue
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: currentUser.Name,
|
||||||
|
downloads: userDownloads
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch user downloads', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all users with their download counts
|
||||||
|
router.get('/user-summary', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// 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: [] }))
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Count downloads per user
|
||||||
|
const userDownloads = {};
|
||||||
|
usersResponse.data.forEach(user => {
|
||||||
|
userDownloads[user.Name.toLowerCase()] = {
|
||||||
|
username: user.Name,
|
||||||
|
seriesCount: 0,
|
||||||
|
movieCount: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process series tags
|
||||||
|
sonarrSeries.data.forEach(series => {
|
||||||
|
const userTag = extractUserTag(series.tags);
|
||||||
|
if (userTag) {
|
||||||
|
const username = userTag.toLowerCase();
|
||||||
|
if (userDownloads[username]) {
|
||||||
|
userDownloads[username].seriesCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process movie tags
|
||||||
|
radarrMovies.data.forEach(movie => {
|
||||||
|
const userTag = extractUserTag(movie.tags);
|
||||||
|
if (userTag) {
|
||||||
|
const username = userTag.toLowerCase();
|
||||||
|
if (userDownloads[username]) {
|
||||||
|
userDownloads[username].movieCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(Object.values(userDownloads));
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch user summary', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
66
server/routes/emby.js
Normal file
66
server/routes/emby.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// Get active sessions
|
||||||
|
router.get('/sessions', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${EMBY_URL}/Sessions`, {
|
||||||
|
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Emby sessions', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user by ID
|
||||||
|
router.get('/users/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${EMBY_URL}/Users/${req.params.id}`, {
|
||||||
|
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch user details', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all users
|
||||||
|
router.get('/users', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${EMBY_URL}/Users`, {
|
||||||
|
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch users', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current user by session ID
|
||||||
|
router.get('/session/:sessionId/user', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${EMBY_URL}/Sessions`, {
|
||||||
|
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = response.data.find(s => s.Id === req.params.sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return res.status(404).json({ error: 'Session not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userResponse = await axios.get(`${EMBY_URL}/Users/${session.UserId}`, {
|
||||||
|
headers: { 'X-MediaBrowser-Token': EMBY_API_KEY }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(userResponse.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch user from session', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
57
server/routes/radarr.js
Normal file
57
server/routes/radarr.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const axios = require('axios');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const RADARR_URL = process.env.RADARR_URL;
|
||||||
|
const RADARR_API_KEY = process.env.RADARR_API_KEY;
|
||||||
|
|
||||||
|
// Get queue
|
||||||
|
router.get('/queue', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${RADARR_URL}/api/v3/queue`, {
|
||||||
|
headers: { 'X-Api-Key': RADARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Radarr queue', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get history
|
||||||
|
router.get('/history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${RADARR_URL}/api/v3/history`, {
|
||||||
|
headers: { 'X-Api-Key': RADARR_API_KEY },
|
||||||
|
params: { pageSize: req.query.pageSize || 50 }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Radarr history', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get movie details
|
||||||
|
router.get('/movies/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${RADARR_URL}/api/v3/movie/${req.params.id}`, {
|
||||||
|
headers: { 'X-Api-Key': RADARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch movie details', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all movies with tags
|
||||||
|
router.get('/movies', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${RADARR_URL}/api/v3/movie`, {
|
||||||
|
headers: { 'X-Api-Key': RADARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch movies', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
41
server/routes/sabnzbd.js
Normal file
41
server/routes/sabnzbd.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
// Get current queue
|
||||||
|
router.get('/queue', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${SABNZBD_URL}/api`, {
|
||||||
|
params: {
|
||||||
|
mode: 'queue',
|
||||||
|
apikey: SABNZBD_API_KEY,
|
||||||
|
output: 'json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch SABnzbd queue', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get history
|
||||||
|
router.get('/history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${SABNZBD_URL}/api`, {
|
||||||
|
params: {
|
||||||
|
mode: 'history',
|
||||||
|
apikey: SABNZBD_API_KEY,
|
||||||
|
output: 'json',
|
||||||
|
limit: req.query.limit || 50
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch SABnzbd history', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
57
server/routes/sonarr.js
Normal file
57
server/routes/sonarr.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const axios = require('axios');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const SONARR_URL = process.env.SONARR_URL;
|
||||||
|
const SONARR_API_KEY = process.env.SONARR_API_KEY;
|
||||||
|
|
||||||
|
// Get queue
|
||||||
|
router.get('/queue', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${SONARR_URL}/api/v3/queue`, {
|
||||||
|
headers: { 'X-Api-Key': SONARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Sonarr queue', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get history
|
||||||
|
router.get('/history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${SONARR_URL}/api/v3/history`, {
|
||||||
|
headers: { 'X-Api-Key': SONARR_API_KEY },
|
||||||
|
params: { pageSize: req.query.pageSize || 50 }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Sonarr history', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get series details
|
||||||
|
router.get('/series/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${SONARR_URL}/api/v3/series/${req.params.id}`, {
|
||||||
|
headers: { 'X-Api-Key': SONARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch series details', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all series with tags
|
||||||
|
router.get('/series', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${SONARR_URL}/api/v3/series`, {
|
||||||
|
headers: { 'X-Api-Key': SONARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch series', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Reference in New Issue
Block a user