Initial commit: Media Download Dashboard with SABnzbd, Sonarr, Radarr, and Emby integration
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user