Migrate frontend from monolithic app.js to vanilla ES modules
Some checks failed
Some checks failed
- Delete React files (App.jsx, main.jsx, App.css) - Create modular vanilla JS structure in client/src/: - state.js (global state object) - api.js (all fetch calls) - sse.js (SSE connection management) - ui/auth.js (authentication UI) - ui/downloads.js (downloads rendering) - ui/history.js (history section) - ui/statusPanel.js (status panel) - ui/webhooks.js (webhook management) - ui/filters.js (download client filter) - ui/theme.js (theme switching) - ui/tabs.js (tab navigation) - utils/format.js (formatting utilities) - utils/storage.js (localStorage helpers) - main.js (DOMContentLoaded bootstrap) - Update vite.config.js for vanilla build outputting to ../public/app.js - Build succeeds: 14 modules, 43.88 kB output
This commit is contained in:
@@ -1,499 +0,0 @@
|
||||
* {
|
||||
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;
|
||||
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-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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Webhooks Section Styles */
|
||||
.webhooks-section {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.webhooks-header {
|
||||
padding: 20px 30px;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.webhooks-header:hover {
|
||||
background: #f0f1f2;
|
||||
}
|
||||
|
||||
.webhooks-header h2 {
|
||||
color: #333;
|
||||
font-size: 1.3rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.webhooks-toggle {
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.webhooks-toggle.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.webhooks-content {
|
||||
padding: 20px 30px;
|
||||
}
|
||||
|
||||
.webhook-instance {
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.webhook-instance:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.webhook-instance h3 {
|
||||
color: #333;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.webhook-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.status-indicator.enabled {
|
||||
background: #e8f5e9;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.status-indicator.disabled {
|
||||
background: #f5f5f5;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.enable-webhook-btn {
|
||||
padding: 8px 16px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.enable-webhook-btn:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.enable-webhook-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.test-webhook-btn {
|
||||
padding: 8px 16px;
|
||||
background: #f093fb;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.test-webhook-btn:hover {
|
||||
background: #d97ed8;
|
||||
}
|
||||
|
||||
.test-webhook-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.webhook-triggers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.trigger-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trigger-label {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.trigger-value {
|
||||
font-weight: 500;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.trigger-value.active {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.trigger-value.inactive {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.webhook-stats {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.webhook-stats-title {
|
||||
color: #999;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.webhook-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.webhook-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.webhook-stat-label {
|
||||
color: #999;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.webhook-stat-value {
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
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([]);
|
||||
const [webhookSectionExpanded, setWebhookSectionExpanded] = useState(false);
|
||||
const [sonarrWebhook, setSonarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null });
|
||||
const [radarrWebhook, setRadarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null });
|
||||
const [webhookMetrics, setWebhookMetrics] = useState(null);
|
||||
const [webhookLoading, setWebhookLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
fetchWebhookStatus();
|
||||
}, []);
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
const formatTimeAgo = (timestamp) => {
|
||||
if (!timestamp) return 'Never';
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
};
|
||||
|
||||
const fetchWebhookMetrics = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/dashboard/webhook-metrics');
|
||||
setWebhookMetrics(response.data);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
// Not fatal — stats just won't display
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchWebhookStatus = async () => {
|
||||
try {
|
||||
// Fetch metrics in parallel with notification status
|
||||
const metricsPromise = fetchWebhookMetrics();
|
||||
|
||||
// Fetch Sonarr notifications
|
||||
let sonarrEnabled = false;
|
||||
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
||||
try {
|
||||
const sonarrResponse = await axios.get('/api/sonarr/notifications');
|
||||
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
|
||||
sonarrEnabled = !!sonarrSofarr;
|
||||
if (sonarrSofarr) {
|
||||
sonarrTriggers = {
|
||||
onGrab: sonarrSofarr.onGrab,
|
||||
onDownload: sonarrSofarr.onDownload,
|
||||
onImport: sonarrSofarr.onImport,
|
||||
onUpgrade: sonarrSofarr.onUpgrade
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
// Sonarr not configured or not accessible
|
||||
}
|
||||
|
||||
// Fetch Radarr notifications
|
||||
let radarrEnabled = false;
|
||||
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
||||
try {
|
||||
const radarrResponse = await axios.get('/api/radarr/notifications');
|
||||
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
|
||||
radarrEnabled = !!radarrSofarr;
|
||||
if (radarrSofarr) {
|
||||
radarrTriggers = {
|
||||
onGrab: radarrSofarr.onGrab,
|
||||
onDownload: radarrSofarr.onDownload,
|
||||
onImport: radarrSofarr.onImport,
|
||||
onUpgrade: radarrSofarr.onUpgrade
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
// Radarr not configured or not accessible
|
||||
}
|
||||
|
||||
const metrics = await metricsPromise;
|
||||
|
||||
// Attach per-instance stats from global metrics.
|
||||
// The instances object is keyed by instance URL; we pick the first
|
||||
// sonarr/radarr entry by matching env-configured URLs.
|
||||
const instanceEntries = metrics ? Object.entries(metrics.instances || {}) : [];
|
||||
const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null;
|
||||
const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null;
|
||||
|
||||
setSonarrWebhook({ enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats });
|
||||
setRadarrWebhook({ enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats });
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch webhook status:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const enableSonarrWebhook = async () => {
|
||||
setWebhookLoading(true);
|
||||
try {
|
||||
await axios.post('/api/sonarr/notifications/sofarr-webhook');
|
||||
await fetchWebhookStatus();
|
||||
} catch (err) {
|
||||
console.error('Failed to enable Sonarr webhook:', err);
|
||||
alert('Failed to enable Sonarr webhook. Check console for details.');
|
||||
} finally {
|
||||
setWebhookLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const enableRadarrWebhook = async () => {
|
||||
setWebhookLoading(true);
|
||||
try {
|
||||
await axios.post('/api/radarr/notifications/sofarr-webhook');
|
||||
await fetchWebhookStatus();
|
||||
} catch (err) {
|
||||
console.error('Failed to enable Radarr webhook:', err);
|
||||
alert('Failed to enable Radarr webhook. Check console for details.');
|
||||
} finally {
|
||||
setWebhookLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testSonarrWebhook = async () => {
|
||||
setWebhookLoading(true);
|
||||
try {
|
||||
const sonarrResponse = await axios.get('/api/sonarr/notifications');
|
||||
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
|
||||
if (sonarrSofarr) {
|
||||
await axios.post('/api/sonarr/notifications/test', { id: sonarrSofarr.id });
|
||||
await fetchWebhookStatus();
|
||||
alert('Sonarr webhook test sent successfully!');
|
||||
} else {
|
||||
alert('Sofarr webhook not configured for Sonarr.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to test Sonarr webhook:', err);
|
||||
alert('Failed to test Sonarr webhook. Check console for details.');
|
||||
} finally {
|
||||
setWebhookLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testRadarrWebhook = async () => {
|
||||
setWebhookLoading(true);
|
||||
try {
|
||||
const radarrResponse = await axios.get('/api/radarr/notifications');
|
||||
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
|
||||
if (radarrSofarr) {
|
||||
await axios.post('/api/radarr/notifications/test', { id: radarrSofarr.id });
|
||||
await fetchWebhookStatus();
|
||||
alert('Radarr webhook test sent successfully!');
|
||||
} else {
|
||||
alert('Sofarr webhook not configured for Radarr.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to test Radarr webhook:', err);
|
||||
alert('Failed to test Radarr webhook. Check console for details.');
|
||||
} finally {
|
||||
setWebhookLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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}`}>
|
||||
{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'}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="webhooks-section">
|
||||
<div className="webhooks-header" onClick={() => setWebhookSectionExpanded(!webhookSectionExpanded)}>
|
||||
<h2>⚡ Webhooks Configuration</h2>
|
||||
<span className={`webhooks-toggle ${webhookSectionExpanded ? 'expanded' : ''}`}>▼</span>
|
||||
</div>
|
||||
{webhookSectionExpanded && (
|
||||
<div className="webhooks-content">
|
||||
{webhookLoading && <div className="loading">Loading webhook status...</div>}
|
||||
<div className="webhook-instance">
|
||||
<h3>Sonarr</h3>
|
||||
<div className="webhook-status">
|
||||
<span className={`status-indicator ${sonarrWebhook.enabled ? 'enabled' : 'disabled'}`}>
|
||||
{sonarrWebhook.enabled ? '● Enabled' : '○ Disabled'}
|
||||
</span>
|
||||
{!sonarrWebhook.enabled && (
|
||||
<button onClick={enableSonarrWebhook} className="enable-webhook-btn" disabled={webhookLoading}>
|
||||
Enable Sofarr Webhooks
|
||||
</button>
|
||||
)}
|
||||
{sonarrWebhook.enabled && (
|
||||
<button onClick={testSonarrWebhook} className="test-webhook-btn" disabled={webhookLoading}>
|
||||
Test
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{sonarrWebhook.enabled && (
|
||||
<div className="webhook-triggers">
|
||||
<div className="trigger-item">
|
||||
<span className="trigger-label">On Grab</span>
|
||||
<span className={`trigger-value ${sonarrWebhook.triggers.onGrab ? 'active' : 'inactive'}`}>
|
||||
{sonarrWebhook.triggers.onGrab ? '✓' : '✗'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="trigger-item">
|
||||
<span className="trigger-label">On Download</span>
|
||||
<span className={`trigger-value ${sonarrWebhook.triggers.onDownload ? 'active' : 'inactive'}`}>
|
||||
{sonarrWebhook.triggers.onDownload ? '✓' : '✗'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="trigger-item">
|
||||
<span className="trigger-label">On Import</span>
|
||||
<span className={`trigger-value ${sonarrWebhook.triggers.onImport ? 'active' : 'inactive'}`}>
|
||||
{sonarrWebhook.triggers.onImport ? '✓' : '✗'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="trigger-item">
|
||||
<span className="trigger-label">On Upgrade</span>
|
||||
<span className={`trigger-value ${sonarrWebhook.triggers.onUpgrade ? 'active' : 'inactive'}`}>
|
||||
{sonarrWebhook.triggers.onUpgrade ? '✓' : '✗'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sonarrWebhook.stats && (
|
||||
<div className="webhook-stats">
|
||||
<div className="webhook-stats-title">Statistics</div>
|
||||
<div className="webhook-stats-grid">
|
||||
<div className="webhook-stat">
|
||||
<span className="webhook-stat-label">Events Received</span>
|
||||
<span className="webhook-stat-value">{sonarrWebhook.stats.eventsReceived ?? 0}</span>
|
||||
</div>
|
||||
<div className="webhook-stat">
|
||||
<span className="webhook-stat-label">Polls Skipped</span>
|
||||
<span className="webhook-stat-value">{sonarrWebhook.stats.pollsSkipped ?? 0}</span>
|
||||
</div>
|
||||
<div className="webhook-stat">
|
||||
<span className="webhook-stat-label">Last Event</span>
|
||||
<span className="webhook-stat-value">{formatTimeAgo(sonarrWebhook.stats.lastWebhookTimestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="webhook-instance">
|
||||
<h3>Radarr</h3>
|
||||
<div className="webhook-status">
|
||||
<span className={`status-indicator ${radarrWebhook.enabled ? 'enabled' : 'disabled'}`}>
|
||||
{radarrWebhook.enabled ? '● Enabled' : '○ Disabled'}
|
||||
</span>
|
||||
{!radarrWebhook.enabled && (
|
||||
<button onClick={enableRadarrWebhook} className="enable-webhook-btn" disabled={webhookLoading}>
|
||||
Enable Sofarr Webhooks
|
||||
</button>
|
||||
)}
|
||||
{radarrWebhook.enabled && (
|
||||
<button onClick={testRadarrWebhook} className="test-webhook-btn" disabled={webhookLoading}>
|
||||
Test
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{radarrWebhook.enabled && (
|
||||
<div className="webhook-triggers">
|
||||
<div className="trigger-item">
|
||||
<span className="trigger-label">On Grab</span>
|
||||
<span className={`trigger-value ${radarrWebhook.triggers.onGrab ? 'active' : 'inactive'}`}>
|
||||
{radarrWebhook.triggers.onGrab ? '✓' : '✗'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="trigger-item">
|
||||
<span className="trigger-label">On Download</span>
|
||||
<span className={`trigger-value ${radarrWebhook.triggers.onDownload ? 'active' : 'inactive'}`}>
|
||||
{radarrWebhook.triggers.onDownload ? '✓' : '✗'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="trigger-item">
|
||||
<span className="trigger-label">On Import</span>
|
||||
<span className={`trigger-value ${radarrWebhook.triggers.onImport ? 'active' : 'inactive'}`}>
|
||||
{radarrWebhook.triggers.onImport ? '✓' : '✗'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="trigger-item">
|
||||
<span className="trigger-label">On Upgrade</span>
|
||||
<span className={`trigger-value ${radarrWebhook.triggers.onUpgrade ? 'active' : 'inactive'}`}>
|
||||
{radarrWebhook.triggers.onUpgrade ? '✓' : '✗'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{radarrWebhook.stats && (
|
||||
<div className="webhook-stats">
|
||||
<div className="webhook-stats-title">Statistics</div>
|
||||
<div className="webhook-stats-grid">
|
||||
<div className="webhook-stat">
|
||||
<span className="webhook-stat-label">Events Received</span>
|
||||
<span className="webhook-stat-value">{radarrWebhook.stats.eventsReceived ?? 0}</span>
|
||||
</div>
|
||||
<div className="webhook-stat">
|
||||
<span className="webhook-stat-label">Polls Skipped</span>
|
||||
<span className="webhook-stat-value">{radarrWebhook.stats.pollsSkipped ?? 0}</span>
|
||||
</div>
|
||||
<div className="webhook-stat">
|
||||
<span className="webhook-stat-label">Last Event</span>
|
||||
<span className="webhook-stat-value">{formatTimeAgo(radarrWebhook.stats.lastWebhookTimestamp)}</span>
|
||||
</div>
|
||||
</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;
|
||||
292
client/src/api.js
Normal file
292
client/src/api.js
Normal file
@@ -0,0 +1,292 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state } from './state.js';
|
||||
|
||||
export async function checkAuthentication() {
|
||||
try {
|
||||
// Fetch both auth state and a fresh CSRF token in parallel
|
||||
const [meRes, csrfRes] = await Promise.all([
|
||||
fetch('/api/auth/me'),
|
||||
fetch('/api/auth/csrf')
|
||||
]);
|
||||
const data = await meRes.json();
|
||||
const csrfData = await csrfRes.json();
|
||||
if (csrfData.csrfToken) state.csrfToken = csrfData.csrfToken;
|
||||
|
||||
if (data.authenticated) {
|
||||
state.currentUser = data.user;
|
||||
state.isAdmin = !!data.user.isAdmin;
|
||||
return { authenticated: true, user: data.user };
|
||||
} else {
|
||||
return { authenticated: false };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Authentication check failed:', err);
|
||||
return { authenticated: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleLogin(username, password, rememberMe) {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password, rememberMe })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
state.currentUser = data.user;
|
||||
state.isAdmin = !!data.user.isAdmin;
|
||||
// Store CSRF token returned by login for use in subsequent requests
|
||||
if (data.csrfToken) state.csrfToken = data.csrfToken;
|
||||
return { success: true, user: data.user };
|
||||
} else {
|
||||
return { success: false, error: data.error || 'Login failed' };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return { success: false, error: 'Login failed. Please try again.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleLogout() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
|
||||
});
|
||||
currentUser = null;
|
||||
csrfToken = null;
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Logout failed:', err);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadHistory(forceRefresh = false) {
|
||||
try {
|
||||
const params = new URLSearchParams({ days: state.historyDays });
|
||||
if (state.showAll) params.set('showAll', 'true');
|
||||
if (forceRefresh) params.set('_t', Date.now());
|
||||
const res = await fetch(`/api/history/recent?${params}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
return { success: true, history: data.history || [] };
|
||||
} catch (err) {
|
||||
console.error('[History] Load error:', err);
|
||||
return { success: false, error: 'Failed to load history.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleBlocklistSearch(download) {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/blocklist-search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
arrQueueId: download.arrQueueId,
|
||||
arrType: download.arrType,
|
||||
arrInstanceUrl: download.arrInstanceUrl,
|
||||
arrInstanceKey: download.arrInstanceKey,
|
||||
arrContentId: download.arrContentId,
|
||||
arrContentType: download.arrContentType
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('[Blocklist] Error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAppVersion() {
|
||||
try {
|
||||
const res = await fetch('/health');
|
||||
const data = await res.json();
|
||||
return data.version || null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWebhookMetrics() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/webhook-metrics');
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWebhookStatus() {
|
||||
try {
|
||||
// Fetch metrics in parallel
|
||||
const metricsPromise = fetchWebhookMetrics();
|
||||
|
||||
// Fetch Sonarr notifications
|
||||
let sonarrEnabled = false;
|
||||
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
||||
try {
|
||||
const sonarrRes = await fetch('/api/sonarr/notifications');
|
||||
if (sonarrRes.ok) {
|
||||
const sonarrData = await sonarrRes.json();
|
||||
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
|
||||
sonarrEnabled = !!sonarrSofarr;
|
||||
if (sonarrSofarr) {
|
||||
sonarrTriggers = {
|
||||
onGrab: sonarrSofarr.onGrab,
|
||||
onDownload: sonarrSofarr.onDownload,
|
||||
onImport: sonarrSofarr.onImport,
|
||||
onUpgrade: sonarrSofarr.onUpgrade
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Sonarr not configured
|
||||
}
|
||||
|
||||
// Fetch Radarr notifications
|
||||
let radarrEnabled = false;
|
||||
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
||||
try {
|
||||
const radarrRes = await fetch('/api/radarr/notifications');
|
||||
if (radarrRes.ok) {
|
||||
const radarrData = await radarrRes.json();
|
||||
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
|
||||
radarrEnabled = !!radarrSofarr;
|
||||
if (radarrSofarr) {
|
||||
radarrTriggers = {
|
||||
onGrab: radarrSofarr.onGrab,
|
||||
onDownload: radarrSofarr.onDownload,
|
||||
onImport: radarrSofarr.onImport,
|
||||
onUpgrade: radarrSofarr.onUpgrade
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Radarr not configured
|
||||
}
|
||||
|
||||
state.webhookMetrics = await metricsPromise;
|
||||
|
||||
// Find instance stats
|
||||
const instanceEntries = state.webhookMetrics ? Object.entries(state.webhookMetrics.instances || {}) : [];
|
||||
const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null;
|
||||
const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null;
|
||||
|
||||
state.sonarrWebhook = { enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats };
|
||||
state.radarrWebhook = { enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats };
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch webhook status:', err);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableSonarrWebhook() {
|
||||
try {
|
||||
const res = await fetch('/api/sonarr/notifications/sofarr-webhook', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrfToken || '' }
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to enable');
|
||||
await fetchWebhookStatus();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Failed to enable Sonarr webhook:', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableRadarrWebhook() {
|
||||
try {
|
||||
const res = await fetch('/api/radarr/notifications/sofarr-webhook', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': state.csrfToken || '' }
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to enable');
|
||||
await fetchWebhookStatus();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Failed to enable Radarr webhook:', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSonarrWebhook() {
|
||||
try {
|
||||
const sonarrRes = await fetch('/api/sonarr/notifications');
|
||||
if (!sonarrRes.ok) throw new Error('Failed to fetch notifications');
|
||||
const sonarrData = await sonarrRes.json();
|
||||
const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr');
|
||||
if (!sonarrSofarr) throw new Error('Sofarr webhook not found');
|
||||
|
||||
const res = await fetch('/api/sonarr/notifications/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': state.csrfToken || ''
|
||||
},
|
||||
body: JSON.stringify(sonarrSofarr)
|
||||
});
|
||||
if (!res.ok) throw new Error('Test failed');
|
||||
await fetchWebhookStatus();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Failed to test Sonarr webhook:', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function testRadarrWebhook() {
|
||||
try {
|
||||
const radarrRes = await fetch('/api/radarr/notifications');
|
||||
if (!radarrRes.ok) throw new Error('Failed to fetch notifications');
|
||||
const radarrData = await radarrRes.json();
|
||||
const radarrSofarr = radarrData.find(n => n.name === 'Sofarr');
|
||||
if (!radarrSofarr) throw new Error('Sofarr webhook not found');
|
||||
|
||||
const res = await fetch('/api/radarr/notifications/test', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': state.csrfToken || ''
|
||||
},
|
||||
body: JSON.stringify(radarrSofarr)
|
||||
});
|
||||
if (!res.ok) throw new Error('Test failed');
|
||||
await fetchWebhookStatus();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Failed to test Radarr webhook:', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshStatusPanel() {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/status');
|
||||
if (!res.ok) throw new Error('Failed to fetch status: ' + res.status);
|
||||
const data = await res.json();
|
||||
return { success: true, data };
|
||||
} catch (err) {
|
||||
console.error('[Status] Error fetching status:', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
62
client/src/main.js
Normal file
62
client/src/main.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
// Bootstrap - wire all event handlers on DOMContentLoaded
|
||||
import { checkAuthenticationAndInit, handleLogin, handleLogoutClick } from './ui/auth.js';
|
||||
import { initDownloadClientFilter } from './ui/filters.js';
|
||||
import { initHistoryControls } from './ui/history.js';
|
||||
import { toggleStatusPanel } from './ui/statusPanel.js';
|
||||
import { initWebhooks } from './ui/webhooks.js';
|
||||
import { initThemeSwitcher } from './ui/theme.js';
|
||||
import { initTabs, goHome } from './ui/tabs.js';
|
||||
import { handleShowAllToggle } from './sse.js';
|
||||
import { loadAppVersion } from './api.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Login form
|
||||
const loginForm = document.getElementById('login-form');
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener('submit', handleLogin);
|
||||
}
|
||||
|
||||
// Logout button
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', handleLogoutClick);
|
||||
}
|
||||
|
||||
// Show all toggle
|
||||
const showAllToggle = document.getElementById('show-all-toggle');
|
||||
if (showAllToggle) {
|
||||
showAllToggle.addEventListener('change', (e) => handleShowAllToggle(e.target.checked));
|
||||
}
|
||||
|
||||
// Status panel toggle
|
||||
const statusToggle = document.getElementById('status-toggle');
|
||||
if (statusToggle) {
|
||||
statusToggle.addEventListener('click', toggleStatusPanel);
|
||||
}
|
||||
|
||||
// Home button
|
||||
const homeBtn = document.getElementById('home-btn');
|
||||
if (homeBtn) {
|
||||
homeBtn.addEventListener('click', goHome);
|
||||
}
|
||||
|
||||
// Initialize UI components
|
||||
initThemeSwitcher();
|
||||
initTabs();
|
||||
initDownloadClientFilter();
|
||||
initHistoryControls();
|
||||
initWebhooks();
|
||||
|
||||
// Load app version
|
||||
loadAppVersion().then(version => {
|
||||
const versionEl = document.getElementById('app-version');
|
||||
if (versionEl && version) {
|
||||
versionEl.textContent = 'v' + version;
|
||||
}
|
||||
});
|
||||
|
||||
// Check authentication and initialize
|
||||
checkAuthenticationAndInit();
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
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>,
|
||||
)
|
||||
60
client/src/sse.js
Normal file
60
client/src/sse.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state, SSE_RECONNECT_MS } from './state.js';
|
||||
import { renderDownloads } from './ui/downloads.js';
|
||||
import { hideError, hideLoading } from './ui/auth.js';
|
||||
|
||||
export function startSSE() {
|
||||
stopSSE();
|
||||
const params = state.showAll ? '?showAll=true' : '';
|
||||
const source = new EventSource('/api/dashboard/stream' + params);
|
||||
state.sseSource = source;
|
||||
|
||||
let firstMessage = true;
|
||||
source.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
state.currentUser = data.user;
|
||||
state.isAdmin = !!data.isAdmin;
|
||||
state.downloads = data.downloads;
|
||||
// Store download clients and update filter dropdown
|
||||
if (data.downloadClients) {
|
||||
state.downloadClients = data.downloadClients;
|
||||
// Trigger filter update
|
||||
const filterUpdateEvent = new CustomEvent('downloadClientsUpdated');
|
||||
document.dispatchEvent(filterUpdateEvent);
|
||||
}
|
||||
document.getElementById('currentUser').textContent = state.currentUser || '-';
|
||||
renderDownloads();
|
||||
hideError();
|
||||
if (firstMessage) { firstMessage = false; hideLoading(); }
|
||||
} catch (err) {
|
||||
console.error('[SSE] Failed to parse message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
source.onerror = () => {
|
||||
// EventSource retries automatically; we just log and show a reconnecting indicator
|
||||
console.warn('[SSE] Connection lost, browser will retry...');
|
||||
};
|
||||
|
||||
console.log('[SSE] Stream connected');
|
||||
}
|
||||
|
||||
export function stopSSE() {
|
||||
if (state.sseReconnectTimer) { clearTimeout(state.sseReconnectTimer); state.sseReconnectTimer = null; }
|
||||
if (state.sseSource) {
|
||||
state.sseSource.close();
|
||||
state.sseSource = null;
|
||||
console.log('[SSE] Stream closed');
|
||||
}
|
||||
}
|
||||
|
||||
export function handleShowAllToggle(checked) {
|
||||
state.showAll = checked;
|
||||
// Re-open stream with updated showAll param
|
||||
startSSE();
|
||||
// Trigger history reload with updated showAll param
|
||||
const historyReloadEvent = new CustomEvent('historyReload');
|
||||
document.dispatchEvent(historyReloadEvent);
|
||||
}
|
||||
38
client/src/state.js
Normal file
38
client/src/state.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
// Global state (using objects for mutability across modules)
|
||||
export const state = {
|
||||
currentUser: null,
|
||||
downloads: [],
|
||||
downloadClients: [], // List of download clients from server (for ordering/filtering)
|
||||
selectedDownloadClients: [], // Array of selected client IDs for multi-select filter
|
||||
isAdmin: false,
|
||||
showAll: false,
|
||||
csrfToken: null, // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
|
||||
|
||||
// History section state
|
||||
historyDays: 7, // Default value, will be loaded from localStorage
|
||||
historyRefreshHandle: null,
|
||||
ignoreAvailable: false, // Default value, will be loaded from localStorage
|
||||
lastHistoryItems: [], // raw items from last fetch, for re-filtering without a network round-trip
|
||||
|
||||
// SSE stream state
|
||||
sseSource: null,
|
||||
sseReconnectTimer: null,
|
||||
|
||||
// Status panel state
|
||||
statusRefreshHandle: null,
|
||||
|
||||
// Webhooks state
|
||||
webhookSectionExpanded: false,
|
||||
webhookLoading: false,
|
||||
sonarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
|
||||
radarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null },
|
||||
webhookMetrics: null
|
||||
};
|
||||
|
||||
// Constants
|
||||
export const SPLASH_MIN_MS = 1200; // minimum splash display time
|
||||
export const HISTORY_REFRESH_MS = 5 * 60 * 1000; // auto-refresh history every 5 min
|
||||
export const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit backoff too
|
||||
export const STATUS_REFRESH_MS = 5000;
|
||||
171
client/src/ui/auth.js
Normal file
171
client/src/ui/auth.js
Normal file
@@ -0,0 +1,171 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state, SPLASH_MIN_MS } from '../state.js';
|
||||
import { checkAuthentication, handleLogin as apiHandleLogin, handleLogout as apiHandleLogout } from '../api.js';
|
||||
import { startSSE, stopSSE } from '../sse.js';
|
||||
import { stopHistoryRefresh, clearHistory, startHistoryRefresh } from './history.js';
|
||||
import { closeStatusPanel } from './statusPanel.js';
|
||||
|
||||
export function fadeOutLogin() {
|
||||
return new Promise(resolve => {
|
||||
const login = document.getElementById('login-container');
|
||||
login.classList.add('fade-out');
|
||||
login.addEventListener('transitionend', () => {
|
||||
login.style.display = 'none';
|
||||
login.classList.remove('fade-out');
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export function showSplash() {
|
||||
const splash = document.getElementById('splash-screen');
|
||||
splash.style.display = 'flex';
|
||||
splash.style.opacity = '1';
|
||||
splash.classList.remove('fade-out');
|
||||
}
|
||||
|
||||
export function dismissSplash(startTime) {
|
||||
return new Promise(resolve => {
|
||||
const elapsed = Date.now() - (startTime || 0);
|
||||
const remaining = Math.max(0, SPLASH_MIN_MS - elapsed);
|
||||
setTimeout(() => {
|
||||
const splash = document.getElementById('splash-screen');
|
||||
splash.classList.add('fade-out');
|
||||
// Fallback: resolve after transition duration + buffer in case
|
||||
// transitionend never fires (e.g. display was toggled in same frame)
|
||||
const TRANSITION_MS = 400;
|
||||
const fallback = setTimeout(() => {
|
||||
splash.style.display = 'none';
|
||||
resolve();
|
||||
}, TRANSITION_MS + 100);
|
||||
splash.addEventListener('transitionend', () => {
|
||||
clearTimeout(fallback);
|
||||
splash.style.display = 'none';
|
||||
resolve();
|
||||
}, { once: true });
|
||||
}, remaining);
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkAuthenticationAndInit() {
|
||||
const splashStart = Date.now();
|
||||
try {
|
||||
const result = await checkAuthentication();
|
||||
if (result.authenticated) {
|
||||
showDashboard();
|
||||
showLoading();
|
||||
startSSE();
|
||||
await dismissSplash(splashStart);
|
||||
} else {
|
||||
await dismissSplash(splashStart);
|
||||
showLogin();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Authentication check failed:', err);
|
||||
await dismissSplash(splashStart);
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleLogin(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const rememberMe = document.getElementById('remember-me').checked;
|
||||
|
||||
try {
|
||||
const result = await apiHandleLogin(username, password, rememberMe);
|
||||
|
||||
if (result.success) {
|
||||
// Fade out login, then show splash while opening SSE stream.
|
||||
// requestAnimationFrame ensures the browser paints the splash at
|
||||
// opacity:1 before dismissSplash adds fade-out, so the CSS
|
||||
// transition fires and transitionend is guaranteed.
|
||||
await fadeOutLogin();
|
||||
showSplash();
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
showDashboard();
|
||||
showLoading();
|
||||
const splashStart = Date.now();
|
||||
startSSE();
|
||||
await dismissSplash(splashStart);
|
||||
} else {
|
||||
showLoginError(result.error || 'Login failed');
|
||||
}
|
||||
} catch (err) {
|
||||
showLoginError('Login failed. Please try again.');
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleLogoutClick() {
|
||||
try {
|
||||
stopSSE();
|
||||
stopHistoryRefresh();
|
||||
if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle.value = null; }
|
||||
await apiHandleLogout();
|
||||
currentUser = null;
|
||||
clearHistory();
|
||||
showLogin();
|
||||
} catch (err) {
|
||||
console.error('Logout failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function showLogin() {
|
||||
document.getElementById('login-container').style.display = 'flex';
|
||||
document.getElementById('dashboard-container').style.display = 'none';
|
||||
hideLoginError();
|
||||
}
|
||||
|
||||
export function showDashboard() {
|
||||
document.getElementById('login-container').style.display = 'none';
|
||||
document.getElementById('dashboard-container').style.display = 'block';
|
||||
document.getElementById('currentUser').textContent = state.currentUser.name || '-';
|
||||
// Always start with status panel hidden (guards against stale display value on re-login)
|
||||
const sp = document.getElementById('status-panel');
|
||||
sp.style.display = 'none';
|
||||
// Also hide webhooks-section to keep them in sync (both show/hide together)
|
||||
const webhooksSection = document.getElementById('webhooks-section');
|
||||
if (webhooksSection) webhooksSection.style.display = 'none';
|
||||
document.getElementById('admin-controls').style.display = state.isAdmin ? 'flex' : 'none';
|
||||
// Note: webhooks-section visibility is controlled by toggleStatusPanel()
|
||||
// Initialise days input from saved value
|
||||
const daysInput = document.getElementById('history-days');
|
||||
if (daysInput) daysInput.value = state.historyDays;
|
||||
startHistoryRefresh();
|
||||
}
|
||||
|
||||
export function showLoginError(message) {
|
||||
const errorDiv = document.getElementById('login-error');
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
export function hideLoginError() {
|
||||
const errorDiv = document.getElementById('login-error');
|
||||
errorDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
export function showError(message) {
|
||||
const errorDiv = document.getElementById('error-message');
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
export function hideError() {
|
||||
const errorDiv = document.getElementById('error-message');
|
||||
errorDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
export function showLoading() {
|
||||
const loading = document.getElementById('loading');
|
||||
loading.style.display = 'block';
|
||||
}
|
||||
|
||||
export function hideLoading() {
|
||||
const loading = document.getElementById('loading');
|
||||
loading.style.display = 'none';
|
||||
}
|
||||
566
client/src/ui/downloads.js
Normal file
566
client/src/ui/downloads.js
Normal file
@@ -0,0 +1,566 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { formatSize, formatSpeed, formatEpisodeInfo, escapeHtml } from '../utils/format.js';
|
||||
import { handleBlocklistSearch } from '../api.js';
|
||||
|
||||
export function renderDownloads() {
|
||||
const downloadsList = document.getElementById('downloads-list');
|
||||
const noDownloads = document.getElementById('no-downloads');
|
||||
|
||||
// Filter downloads by selected clients
|
||||
let filteredDownloads = state.downloads;
|
||||
if (state.selectedDownloadClients.length > 0) {
|
||||
// Map indices to client objects, then filter by both client type and instanceId
|
||||
const selectedClients = state.selectedDownloadClients.map(idx => state.downloadClients[idx]).filter(Boolean);
|
||||
filteredDownloads = state.downloads.filter(d =>
|
||||
selectedClients.some(c => c.type === d.client && c.id === d.instanceId)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort downloads by client order (matching the order in downloadClients)
|
||||
if (state.downloadClients.length > 0) {
|
||||
const clientOrder = new Map(state.downloadClients.map((c, idx) => [c.id, idx]));
|
||||
filteredDownloads = [...filteredDownloads].sort((a, b) => {
|
||||
const orderA = clientOrder.get(a.instanceId) ?? Infinity;
|
||||
const orderB = clientOrder.get(b.instanceId) ?? Infinity;
|
||||
return orderA - orderB;
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredDownloads.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();
|
||||
|
||||
filteredDownloads.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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function updateDownloadCard(card, download) {
|
||||
// Remove old header-right container if it exists
|
||||
const oldRightSide = card.querySelector('.download-header-right');
|
||||
if (oldRightSide) {
|
||||
oldRightSide.remove();
|
||||
}
|
||||
|
||||
// Remove old user badges directly in header
|
||||
const oldBadges = card.querySelectorAll('.download-header .download-user-badge');
|
||||
oldBadges.forEach(badge => badge.remove());
|
||||
|
||||
// Remove old client logo from header (old structure)
|
||||
const oldLogoInHeader = card.querySelector('.download-header .download-client-logo-wrapper');
|
||||
if (oldLogoInHeader) {
|
||||
oldLogoInHeader.remove();
|
||||
}
|
||||
|
||||
// Remove old client logo from card (new structure) if it exists
|
||||
const oldLogoInCard = card.querySelector('.download-card-logo-wrapper');
|
||||
if (oldLogoInCard) {
|
||||
oldLogoInCard.remove();
|
||||
}
|
||||
|
||||
// Add new right-side container with user badge only
|
||||
const header = card.querySelector('.download-header');
|
||||
if (header && !header.querySelector('.download-header-right')) {
|
||||
const rightSide = document.createElement('div');
|
||||
rightSide.className = 'download-header-right';
|
||||
|
||||
if (state.showAll && download.tagBadges && download.tagBadges.length > 0) {
|
||||
const unmatched = download.tagBadges.filter(b => !b.matchedUser);
|
||||
const matched = download.tagBadges.filter(b => b.matchedUser);
|
||||
for (const b of unmatched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge unmatched';
|
||||
badge.textContent = b.label;
|
||||
rightSide.appendChild(badge);
|
||||
}
|
||||
for (const b of matched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge';
|
||||
badge.textContent = b.matchedUser;
|
||||
rightSide.appendChild(badge);
|
||||
}
|
||||
} else if (download.matchedUserTag) {
|
||||
const matchedBadge = document.createElement('span');
|
||||
matchedBadge.className = 'download-user-badge';
|
||||
matchedBadge.textContent = download.matchedUserTag;
|
||||
rightSide.appendChild(matchedBadge);
|
||||
}
|
||||
|
||||
header.appendChild(rightSide);
|
||||
}
|
||||
|
||||
// Add client logo to card (positioned at bottom right via CSS)
|
||||
if (download.client && !card.querySelector('.download-card-logo-wrapper')) {
|
||||
const clientLogoWrapper = document.createElement('span');
|
||||
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
|
||||
|
||||
const clientLogo = document.createElement('img');
|
||||
clientLogo.className = 'download-client-logo';
|
||||
clientLogo.src = `/images/clients/${download.client}.svg`;
|
||||
clientLogo.alt = `${download.instanceName || download.client} icon`;
|
||||
clientLogo.title = download.instanceName || download.client;
|
||||
clientLogo.onerror = () => {
|
||||
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
|
||||
clientLogoWrapper.classList.add('fallback');
|
||||
};
|
||||
|
||||
clientLogoWrapper.appendChild(clientLogo);
|
||||
card.appendChild(clientLogoWrapper);
|
||||
}
|
||||
|
||||
// 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 availabilityItem = card.querySelector('.detail-item[data-label="Availability"]');
|
||||
if (availabilityItem && download.availability !== undefined) {
|
||||
availabilityItem.querySelector('.detail-value').textContent = `${download.availability}%`;
|
||||
availabilityItem.classList.toggle('availability-warning', parseFloat(download.availability) < 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleBlocklistSearchClick(btn, download) {
|
||||
if (!confirm(`Blocklist "${download.title}" and trigger a new search?\n\nThis will:\n• Remove the download from the download client\n• Add this release to the blocklist\n• Trigger an automatic search for a new release`)) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Working…';
|
||||
|
||||
try {
|
||||
await handleBlocklistSearch(download);
|
||||
btn.textContent = '✓ Done — searching…';
|
||||
btn.className = 'blocklist-search-btn success';
|
||||
} catch (err) {
|
||||
console.error('[Blocklist] Error:', err);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '⛔ Blocklist & Search';
|
||||
btn.className = 'blocklist-search-btn error';
|
||||
btn.title = `Failed: ${err.message}`;
|
||||
setTimeout(() => {
|
||||
btn.className = 'blocklist-search-btn';
|
||||
btn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search';
|
||||
}, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
export 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');
|
||||
// Proxy cover art through the server so the CSP img-src 'self' rule
|
||||
// is satisfied (external poster URLs would be blocked otherwise).
|
||||
coverImg.src = download.coverArt
|
||||
? '/api/dashboard/cover-art?url=' + encodeURIComponent(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);
|
||||
|
||||
if (download.importIssues && download.importIssues.length > 0) {
|
||||
const issueBadge = document.createElement('span');
|
||||
issueBadge.className = 'import-issue-badge';
|
||||
issueBadge.textContent = 'Import Pending';
|
||||
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
|
||||
header.appendChild(issueBadge);
|
||||
}
|
||||
|
||||
if ((state.isAdmin || download.canBlocklist) && download.arrQueueId) {
|
||||
const blBtn = document.createElement('button');
|
||||
blBtn.className = 'blocklist-search-btn';
|
||||
blBtn.textContent = '⛔ Blocklist & Search';
|
||||
blBtn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search';
|
||||
blBtn.addEventListener('click', () => handleBlocklistSearchClick(blBtn, download));
|
||||
header.appendChild(blBtn);
|
||||
}
|
||||
|
||||
// Right side container for user badge only
|
||||
const rightSide = document.createElement('div');
|
||||
rightSide.className = 'download-header-right';
|
||||
|
||||
if (state.showAll && download.tagBadges && download.tagBadges.length > 0) {
|
||||
// In showAll mode: render all tags classified by whether they match an Emby user.
|
||||
// Unmatched (no known Emby user) → amber, leftmost.
|
||||
// Matched → show Emby display name in accent colour, rightmost.
|
||||
const unmatched = download.tagBadges.filter(b => !b.matchedUser);
|
||||
const matched = download.tagBadges.filter(b => b.matchedUser);
|
||||
for (const b of unmatched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge unmatched';
|
||||
badge.textContent = b.label;
|
||||
rightSide.appendChild(badge);
|
||||
}
|
||||
for (const b of matched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge';
|
||||
badge.textContent = b.matchedUser;
|
||||
rightSide.appendChild(badge);
|
||||
}
|
||||
} else if (download.matchedUserTag) {
|
||||
// Normal (non-showAll) view: show only the current user's matched tag
|
||||
const matchedBadge = document.createElement('span');
|
||||
matchedBadge.className = 'download-user-badge';
|
||||
matchedBadge.textContent = download.matchedUserTag;
|
||||
rightSide.appendChild(matchedBadge);
|
||||
}
|
||||
|
||||
header.appendChild(rightSide);
|
||||
|
||||
// Add client logo to card (positioned at bottom right via CSS)
|
||||
if (download.client) {
|
||||
const clientLogoWrapper = document.createElement('span');
|
||||
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
|
||||
|
||||
const clientLogo = document.createElement('img');
|
||||
clientLogo.className = 'download-client-logo';
|
||||
clientLogo.src = `/images/clients/${download.client}.svg`;
|
||||
clientLogo.alt = `${download.instanceName || download.client} icon`;
|
||||
clientLogo.title = download.instanceName || download.client;
|
||||
clientLogo.onerror = () => {
|
||||
// Fallback to text if image fails to load
|
||||
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
|
||||
clientLogoWrapper.classList.add('fallback');
|
||||
};
|
||||
|
||||
clientLogoWrapper.appendChild(clientLogo);
|
||||
card.appendChild(clientLogoWrapper);
|
||||
}
|
||||
|
||||
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';
|
||||
if (state.isAdmin && download.arrLink) {
|
||||
series.innerHTML = 'Series: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.seriesName) + '</a>';
|
||||
} else {
|
||||
series.textContent = `Series: ${download.seriesName}`;
|
||||
}
|
||||
infoDiv.appendChild(series);
|
||||
const epEl = formatEpisodeInfo(download.episodes);
|
||||
if (epEl) infoDiv.appendChild(epEl);
|
||||
}
|
||||
|
||||
if (download.movieName) {
|
||||
const movie = document.createElement('p');
|
||||
movie.className = 'download-movie';
|
||||
if (state.isAdmin && download.arrLink) {
|
||||
movie.innerHTML = 'Movie: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.movieName) + '</a>';
|
||||
} else {
|
||||
movie.textContent = `Movie: ${download.movieName}`;
|
||||
}
|
||||
infoDiv.appendChild(movie);
|
||||
}
|
||||
|
||||
if (state.showAll && download.tagBadges && download.tagBadges.length > 0) {
|
||||
// In showAll mode: render all tags classified by whether they match an Emby user.
|
||||
// Unmatched (no known Emby user) → amber, leftmost.
|
||||
// Matched → show Emby display name in accent colour, rightmost.
|
||||
const unmatched = download.tagBadges.filter(b => !b.matchedUser);
|
||||
const matched = download.tagBadges.filter(b => b.matchedUser);
|
||||
for (const b of unmatched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge unmatched';
|
||||
badge.textContent = b.label;
|
||||
header.appendChild(badge);
|
||||
}
|
||||
for (const b of matched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge';
|
||||
badge.textContent = b.matchedUser;
|
||||
header.appendChild(badge);
|
||||
}
|
||||
} else if (download.matchedUserTag) {
|
||||
// Normal (non-showAll) view: show only the current user's matched tag
|
||||
const matchedBadge = document.createElement('span');
|
||||
matchedBadge.className = 'download-user-badge';
|
||||
matchedBadge.textContent = download.matchedUserTag;
|
||||
header.appendChild(matchedBadge);
|
||||
}
|
||||
|
||||
// Add client logo
|
||||
if (download.client) {
|
||||
const clientLogoWrapper = document.createElement('span');
|
||||
clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper';
|
||||
|
||||
const clientLogo = document.createElement('img');
|
||||
clientLogo.className = 'download-client-logo';
|
||||
clientLogo.src = `/images/clients/${download.client}.svg`;
|
||||
clientLogo.alt = `${download.instanceName || download.client} icon`;
|
||||
clientLogo.title = download.instanceName || download.client;
|
||||
clientLogo.onerror = () => {
|
||||
// Fallback to text if image fails to load
|
||||
clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase();
|
||||
clientLogoWrapper.classList.add('fallback');
|
||||
};
|
||||
|
||||
clientLogoWrapper.appendChild(clientLogo);
|
||||
header.appendChild(clientLogoWrapper);
|
||||
}
|
||||
|
||||
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 (only for torrent clients like qBittorrent)
|
||||
if (download.client && (download.client === 'qbittorrent' || download.client === 'rtorrent') && 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 && download.speed > 0) {
|
||||
const speed = createDetailItem('Speed', formatSpeed(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}%`);
|
||||
if (parseFloat(download.availability) < 100) availability.classList.add('availability-warning');
|
||||
details.appendChild(availability);
|
||||
}
|
||||
}
|
||||
|
||||
if (download.completedAt) {
|
||||
const completed = createDetailItem('Completed', formatDate(download.completedAt));
|
||||
details.appendChild(completed);
|
||||
}
|
||||
|
||||
if (state.isAdmin && (download.downloadPath || download.targetPath)) {
|
||||
const pathsDiv = document.createElement('div');
|
||||
pathsDiv.className = 'download-paths';
|
||||
if (download.downloadPath) {
|
||||
const dlPath = document.createElement('div');
|
||||
dlPath.className = 'path-item';
|
||||
dlPath.innerHTML = '<span class="path-label">Download:</span> <span class="path-value">' + escapeHtml(download.downloadPath) + '</span>';
|
||||
pathsDiv.appendChild(dlPath);
|
||||
}
|
||||
if (download.targetPath) {
|
||||
const tgtPath = document.createElement('div');
|
||||
tgtPath.className = 'path-item';
|
||||
tgtPath.innerHTML = '<span class="path-label">Target:</span> <span class="path-value">' + escapeHtml(download.targetPath) + '</span>';
|
||||
pathsDiv.appendChild(tgtPath);
|
||||
}
|
||||
details.appendChild(pathsDiv);
|
||||
}
|
||||
|
||||
infoDiv.appendChild(details);
|
||||
card.appendChild(infoDiv);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
export 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 formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
87
client/src/ui/filters.js
Normal file
87
client/src/ui/filters.js
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { saveDownloadClients } from '../utils/storage.js';
|
||||
import { renderDownloads } from './downloads.js';
|
||||
|
||||
export function initDownloadClientFilter() {
|
||||
const filterBtn = document.getElementById('download-client-filter-btn');
|
||||
const filterDropdown = document.getElementById('download-client-filter-dropdown');
|
||||
const filterClose = document.getElementById('download-client-filter-close');
|
||||
|
||||
if (!filterBtn || !filterDropdown) return;
|
||||
|
||||
filterBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
filterDropdown.style.display = filterDropdown.style.display === 'block' ? 'none' : 'block';
|
||||
});
|
||||
|
||||
filterClose.addEventListener('click', () => {
|
||||
filterDropdown.style.display = 'none';
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!filterDropdown.contains(e.target) && e.target !== filterBtn) {
|
||||
filterDropdown.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for download clients updates from SSE
|
||||
document.addEventListener('downloadClientsUpdated', updateDownloadClientFilter);
|
||||
|
||||
// Initial filter update
|
||||
updateDownloadClientFilter();
|
||||
}
|
||||
|
||||
export function updateDownloadClientFilter() {
|
||||
const filterList = document.getElementById('download-client-filter-list');
|
||||
if (!filterList) return;
|
||||
|
||||
filterList.innerHTML = '';
|
||||
|
||||
state.downloadClients.forEach((client, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'filter-item';
|
||||
item.dataset.index = index;
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.id = `client-${index}`;
|
||||
checkbox.checked = state.selectedDownloadClients.includes(index);
|
||||
checkbox.addEventListener('change', () => toggleClientSelection(index));
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = `client-${index}`;
|
||||
label.textContent = client.name || `${client.type} (${client.id})`;
|
||||
|
||||
item.appendChild(checkbox);
|
||||
item.appendChild(label);
|
||||
filterList.appendChild(item);
|
||||
});
|
||||
|
||||
updateSelectedCountDisplay();
|
||||
}
|
||||
|
||||
export function toggleClientSelection(index) {
|
||||
const idx = state.selectedDownloadClients.indexOf(index);
|
||||
if (idx > -1) {
|
||||
state.selectedDownloadClients.splice(idx, 1);
|
||||
} else {
|
||||
state.selectedDownloadClients.push(index);
|
||||
}
|
||||
saveDownloadClients(state.selectedDownloadClients);
|
||||
updateSelectedCountDisplay();
|
||||
renderDownloads();
|
||||
}
|
||||
|
||||
export function updateSelectedCountDisplay() {
|
||||
const countDisplay = document.getElementById('download-client-filter-count');
|
||||
if (!countDisplay) return;
|
||||
|
||||
if (state.selectedDownloadClients.length === 0) {
|
||||
countDisplay.textContent = 'All';
|
||||
} else {
|
||||
countDisplay.textContent = state.selectedDownloadClients.length;
|
||||
}
|
||||
}
|
||||
244
client/src/ui/history.js
Normal file
244
client/src/ui/history.js
Normal file
@@ -0,0 +1,244 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state, HISTORY_REFRESH_MS } from '../state.js';
|
||||
import { loadHistory as apiLoadHistory } from '../api.js';
|
||||
import { saveHistoryDays, saveIgnoreAvailable } from '../utils/storage.js';
|
||||
import { formatDate, formatEpisodeInfo, escapeHtml } from '../utils/format.js';
|
||||
|
||||
export function initHistoryControls() {
|
||||
const daysInput = document.getElementById('history-days');
|
||||
const refreshBtn = document.getElementById('history-refresh-btn');
|
||||
const ignoreToggle = document.getElementById('ignore-available-toggle');
|
||||
if (daysInput) {
|
||||
daysInput.addEventListener('change', () => {
|
||||
const v = parseInt(daysInput.value, 10);
|
||||
if (v > 0 && v <= 90) {
|
||||
historyDays = v;
|
||||
saveHistoryDays(v);
|
||||
loadHistory(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => loadHistory(true));
|
||||
}
|
||||
if (ignoreToggle) {
|
||||
ignoreToggle.checked = ignoreAvailable;
|
||||
ignoreToggle.addEventListener('change', () => {
|
||||
ignoreAvailable = ignoreToggle.checked;
|
||||
saveIgnoreAvailable(ignoreAvailable);
|
||||
renderHistory(lastHistoryItems);
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for history reload events from other modules
|
||||
document.addEventListener('historyReload', () => {
|
||||
loadHistory(true);
|
||||
});
|
||||
}
|
||||
|
||||
export function startHistoryRefresh() {
|
||||
stopHistoryRefresh();
|
||||
state.historyRefreshHandle = setInterval(() => loadHistory(), HISTORY_REFRESH_MS);
|
||||
}
|
||||
|
||||
export function stopHistoryRefresh() {
|
||||
if (state.historyRefreshHandle) {
|
||||
clearInterval(state.historyRefreshHandle);
|
||||
state.historyRefreshHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearHistory() {
|
||||
state.lastHistoryItems = [];
|
||||
document.getElementById('history-list').innerHTML = '';
|
||||
document.getElementById('no-history').style.display = 'none';
|
||||
document.getElementById('history-error').style.display = 'none';
|
||||
}
|
||||
|
||||
export async function loadHistory(forceRefresh = false) {
|
||||
const listEl = document.getElementById('history-list');
|
||||
const loadingEl = document.getElementById('history-loading');
|
||||
const errorEl = document.getElementById('history-error');
|
||||
const noHistoryEl = document.getElementById('no-history');
|
||||
|
||||
loadingEl.style.display = 'block';
|
||||
errorEl.style.display = 'none';
|
||||
noHistoryEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
const result = await apiLoadHistory(forceRefresh);
|
||||
loadingEl.style.display = 'none';
|
||||
if (result.success) {
|
||||
state.lastHistoryItems = result.history;
|
||||
renderHistory(state.lastHistoryItems);
|
||||
} else {
|
||||
errorEl.textContent = result.error || 'Failed to load history.';
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
} catch (err) {
|
||||
loadingEl.style.display = 'none';
|
||||
errorEl.textContent = 'Failed to load history.';
|
||||
errorEl.style.display = 'block';
|
||||
console.error('[History] Load error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderHistory(items) {
|
||||
const listEl = document.getElementById('history-list');
|
||||
const noHistoryEl = document.getElementById('no-history');
|
||||
listEl.innerHTML = '';
|
||||
const visible = state.ignoreAvailable
|
||||
? items.filter(item => !(item.outcome === 'failed' && item.availableForUpgrade))
|
||||
: items;
|
||||
if (!visible.length) {
|
||||
noHistoryEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
noHistoryEl.style.display = 'none';
|
||||
visible.forEach(item => listEl.appendChild(createHistoryCard(item)));
|
||||
}
|
||||
|
||||
export function createHistoryCard(item) {
|
||||
const card = document.createElement('div');
|
||||
card.className = `history-card ${item.type} ${item.outcome}`;
|
||||
|
||||
if (item.coverArt) {
|
||||
const coverDiv = document.createElement('div');
|
||||
coverDiv.className = 'history-cover';
|
||||
const img = document.createElement('img');
|
||||
img.src = '/api/dashboard/cover-art?url=' + encodeURIComponent(item.coverArt);
|
||||
img.alt = item.movieName || item.seriesName || item.title;
|
||||
img.loading = 'lazy';
|
||||
coverDiv.appendChild(img);
|
||||
card.appendChild(coverDiv);
|
||||
}
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'history-info';
|
||||
|
||||
// Header row: type badge + outcome badge
|
||||
const header = document.createElement('div');
|
||||
header.className = 'history-card-header';
|
||||
|
||||
const typeBadge = document.createElement('span');
|
||||
typeBadge.className = `history-type-badge ${item.type}`;
|
||||
typeBadge.textContent = item.type === 'series' ? '📺 Series' : '🎬 Movie';
|
||||
header.appendChild(typeBadge);
|
||||
|
||||
const outcomeBadge = document.createElement('span');
|
||||
outcomeBadge.className = `history-outcome-badge ${item.outcome}`;
|
||||
outcomeBadge.textContent = item.outcome === 'imported' ? '✓ Imported' : '✗ Failed';
|
||||
header.appendChild(outcomeBadge);
|
||||
|
||||
if (item.availableForUpgrade) {
|
||||
const upgradeBadge = document.createElement('span');
|
||||
upgradeBadge.className = 'history-upgrade-badge';
|
||||
upgradeBadge.title = 'A previous version of this item is available. An upgrade download has failed.';
|
||||
upgradeBadge.textContent = '⬆ Available';
|
||||
header.appendChild(upgradeBadge);
|
||||
}
|
||||
|
||||
if (item.instanceName) {
|
||||
const instBadge = document.createElement('span');
|
||||
instBadge.className = 'history-instance-badge';
|
||||
instBadge.textContent = item.instanceName;
|
||||
header.appendChild(instBadge);
|
||||
}
|
||||
|
||||
if (state.showAll && item.tagBadges && item.tagBadges.length > 0) {
|
||||
const unmatched = item.tagBadges.filter(b => !b.matchedUser);
|
||||
const matched = item.tagBadges.filter(b => b.matchedUser);
|
||||
for (const b of unmatched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge unmatched';
|
||||
badge.textContent = b.label;
|
||||
header.appendChild(badge);
|
||||
}
|
||||
for (const b of matched) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge';
|
||||
badge.textContent = b.matchedUser;
|
||||
header.appendChild(badge);
|
||||
}
|
||||
} else if (item.matchedUserTag) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'download-user-badge';
|
||||
badge.textContent = item.matchedUserTag;
|
||||
header.appendChild(badge);
|
||||
}
|
||||
|
||||
info.appendChild(header);
|
||||
|
||||
// Title
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'history-title';
|
||||
title.textContent = item.title;
|
||||
info.appendChild(title);
|
||||
|
||||
// Series/movie name with optional arr link
|
||||
if (item.seriesName) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'history-media-name';
|
||||
if (state.isAdmin && item.arrLink) {
|
||||
p.innerHTML = 'Series: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.seriesName) + '</a>';
|
||||
} else {
|
||||
p.textContent = 'Series: ' + item.seriesName;
|
||||
}
|
||||
info.appendChild(p);
|
||||
const epEl = formatEpisodeInfo(item.episodes);
|
||||
if (epEl) info.appendChild(epEl);
|
||||
}
|
||||
if (item.movieName) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'history-media-name';
|
||||
if (state.isAdmin && item.arrLink) {
|
||||
p.innerHTML = 'Movie: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.movieName) + '</a>';
|
||||
} else {
|
||||
p.textContent = 'Movie: ' + item.movieName;
|
||||
}
|
||||
info.appendChild(p);
|
||||
}
|
||||
|
||||
// Detail pills
|
||||
const details = document.createElement('div');
|
||||
details.className = 'history-details';
|
||||
|
||||
if (item.completedAt) {
|
||||
details.appendChild(createDetailItem('Completed', formatDate(item.completedAt)));
|
||||
}
|
||||
if (item.quality) {
|
||||
details.appendChild(createDetailItem('Quality', item.quality));
|
||||
}
|
||||
|
||||
// Failed imports: show failure message
|
||||
if (item.outcome === 'failed' && item.failureMessage) {
|
||||
const failItem = document.createElement('div');
|
||||
failItem.className = 'history-failure-message';
|
||||
failItem.textContent = item.failureMessage;
|
||||
details.appendChild(failItem);
|
||||
}
|
||||
|
||||
info.appendChild(details);
|
||||
card.appendChild(info);
|
||||
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;
|
||||
}
|
||||
194
client/src/ui/statusPanel.js
Normal file
194
client/src/ui/statusPanel.js
Normal file
@@ -0,0 +1,194 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state, STATUS_REFRESH_MS } from '../state.js';
|
||||
import { refreshStatusPanel as apiRefreshStatusPanel } from '../api.js';
|
||||
import { fetchWebhookStatus } from './webhooks.js';
|
||||
|
||||
export async function toggleStatusPanel() {
|
||||
const panel = document.getElementById('status-panel');
|
||||
const webhooksSection = document.getElementById('webhooks-section');
|
||||
if (panel.style.display !== 'none') {
|
||||
// Close both panels (webhooks is a sibling, hide it too)
|
||||
panel.style.display = 'none';
|
||||
if (webhooksSection) webhooksSection.style.display = 'none';
|
||||
if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; }
|
||||
return;
|
||||
}
|
||||
// Open status panel and webhooks section (siblings)
|
||||
panel.style.display = 'block';
|
||||
// Show webhooks section for admin users (collapsed by default)
|
||||
if (webhooksSection && state.isAdmin) {
|
||||
webhooksSection.style.display = 'block';
|
||||
state.webhookSectionExpanded = false;
|
||||
document.getElementById('webhooks-content').style.display = 'none';
|
||||
document.getElementById('webhooks-toggle').classList.remove('expanded');
|
||||
await fetchWebhookStatus();
|
||||
} else if (webhooksSection) {
|
||||
webhooksSection.style.display = 'none';
|
||||
}
|
||||
refreshStatusPanel();
|
||||
if (state.statusRefreshHandle) clearInterval(state.statusRefreshHandle);
|
||||
state.statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS);
|
||||
}
|
||||
|
||||
export function closeStatusPanel() {
|
||||
document.getElementById('status-panel').style.display = 'none';
|
||||
const webhooksSection = document.getElementById('webhooks-section');
|
||||
if (webhooksSection) webhooksSection.style.display = 'none';
|
||||
if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; }
|
||||
}
|
||||
|
||||
export async function refreshStatusPanel() {
|
||||
const panel = document.getElementById('status-panel');
|
||||
const contentDiv = document.getElementById('status-content');
|
||||
console.log('[Status] panel found:', !!panel, 'contentDiv found:', !!contentDiv, 'panel display:', panel?.style?.display);
|
||||
if (!panel || panel.style.display === 'none') return;
|
||||
console.log('[Status] Refreshing status panel...');
|
||||
try {
|
||||
const result = await apiRefreshStatusPanel();
|
||||
if (result.success) {
|
||||
console.log('[Status] Got status data, rendering...');
|
||||
renderStatusPanel(result.data, panel);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Status] Error fetching status:', err);
|
||||
// Don't overwrite panel on transient error during auto-refresh
|
||||
if (contentDiv && (!contentDiv.innerHTML || contentDiv.innerHTML.includes('status-loading'))) {
|
||||
contentDiv.innerHTML = '<p class="status-error">Failed to load status: ' + err.message + '</p>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderStatusPanel(data, panel) {
|
||||
console.log('[Status] renderStatusPanel called with data:', data ? 'yes' : 'no', 'keys:', data ? Object.keys(data) : 'none');
|
||||
const s = data.server;
|
||||
const hrs = Math.floor(s.uptimeSeconds / 3600);
|
||||
const mins = Math.floor((s.uptimeSeconds % 3600) / 60);
|
||||
const secs = s.uptimeSeconds % 60;
|
||||
const uptime = `${hrs}h ${mins}m ${secs}s`;
|
||||
|
||||
const totalKB = (data.cache.totalSizeBytes / 1024).toFixed(1);
|
||||
|
||||
let html = `
|
||||
<div class="status-header">
|
||||
<h3>Server Status</h3>
|
||||
<button class="status-close" id="status-close-btn">×</button>
|
||||
</div>
|
||||
<div class="status-grid">
|
||||
<div class="status-card">
|
||||
<div class="status-card-title">Server</div>
|
||||
<div class="status-row"><span>Uptime</span><span>${uptime}</span></div>
|
||||
<div class="status-row"><span>Node</span><span>${escapeHtml(s.nodeVersion)}</span></div>
|
||||
<div class="status-row"><span>Memory (RSS)</span><span>${s.memoryUsageMB} MB</span></div>
|
||||
<div class="status-row"><span>Heap</span><span>${s.heapUsedMB} / ${s.heapTotalMB} MB</span></div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-card-title">Data Refresh</div>`;
|
||||
|
||||
const pollIntervalMs = data.polling.intervalMs;
|
||||
const clients = data.clients || [];
|
||||
const sseClients = clients.filter(c => c.type === 'sse');
|
||||
|
||||
if (data.polling.enabled) {
|
||||
html += `<div class="status-row"><span>Background poll</span><span>${pollIntervalMs / 1000}s</span></div>`;
|
||||
} else {
|
||||
html += `<div class="status-row"><span>Background poll</span><span>Disabled</span></div>`;
|
||||
}
|
||||
|
||||
const mode = sseClients.length > 0
|
||||
? `<span class="status-fg-badge">SSE push</span>`
|
||||
: (data.polling.enabled ? 'Background' : 'On-demand (idle)');
|
||||
html += `<div class="status-row"><span>Delivery mode</span><span>${mode}</span></div>`;
|
||||
|
||||
html += `<div class="status-row"><span>SSE clients</span><span>${sseClients.length}</span></div>`;
|
||||
for (const c of sseClients) {
|
||||
const age = Math.round((Date.now() - c.connectedAt) / 1000);
|
||||
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>connected ${age}s ago</span></div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
// Webhook metrics card (admin only)
|
||||
if (state.isAdmin && data.webhooks) {
|
||||
const wh = data.webhooks;
|
||||
const sonarrEnabled = wh.sonarr?.enabled ? '●' : '○';
|
||||
const radarrEnabled = wh.radarr?.enabled ? '●' : '○';
|
||||
const sonarrEvents = wh.sonarr?.eventsReceived || 0;
|
||||
const radarrEvents = wh.radarr?.eventsReceived || 0;
|
||||
const sonarrPolls = wh.sonarr?.pollsSkipped || 0;
|
||||
const radarrPolls = wh.radarr?.pollsSkipped || 0;
|
||||
|
||||
html += `
|
||||
<div class="status-card">
|
||||
<div class="status-card-title">Webhooks</div>
|
||||
<div class="status-row"><span>Sonarr</span><span>${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div class="status-row"><span>Radarr</span><span>${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Events</span><span>S:${sonarrEvents} R:${radarrEvents}</span></div>
|
||||
<div class="status-row status-row-sub"><span>Polls skipped</span><span>S:${sonarrPolls} R:${radarrPolls}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Poll timings card
|
||||
const lp = data.polling.lastPoll;
|
||||
if (lp) {
|
||||
const pollAge = Math.round((Date.now() - new Date(lp.timestamp).getTime()) / 1000);
|
||||
html += `
|
||||
<div class="status-card status-card-wide">
|
||||
<div class="status-card-title">Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)</div>
|
||||
<div class="status-timings">`;
|
||||
const maxTaskMs = lp.tasks.reduce((max, t) => Math.max(max, t.ms), 1);
|
||||
for (const t of lp.tasks) {
|
||||
const barWidth = Math.max(2, (t.ms / maxTaskMs) * 100);
|
||||
html += `
|
||||
<div class="timing-row">
|
||||
<span class="timing-label">${escapeHtml(t.label)}</span>
|
||||
<div class="timing-bar-bg"><div class="timing-bar" data-w="${barWidth.toFixed(1)}"></div></div>
|
||||
<span class="timing-value">${t.ms}ms</span>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
// Cache table
|
||||
html += `
|
||||
<div class="status-card status-card-wide">
|
||||
<div class="status-card-title">Cache (${data.cache.entryCount} entries, ${totalKB} KB)</div>
|
||||
<table class="status-table">
|
||||
<thead><tr><th>Key</th><th>Items</th><th>Size</th><th>TTL</th></tr></thead>
|
||||
<tbody>`;
|
||||
|
||||
for (const e of data.cache.entries) {
|
||||
const sizeStr = e.sizeBytes > 1024 ? (e.sizeBytes / 1024).toFixed(1) + ' KB' : e.sizeBytes + ' B';
|
||||
const ttlStr = e.expired ? '<span class="status-expired">expired</span>' : (e.ttlRemainingMs / 1000).toFixed(0) + 's';
|
||||
const items = e.itemCount !== null ? e.itemCount : '—';
|
||||
html += `<tr><td><code>${escapeHtml(e.key)}</code></td><td>${items}</td><td>${sizeStr}</td><td>${ttlStr}</td></tr>`;
|
||||
}
|
||||
|
||||
html += `</tbody></table></div></div>`;
|
||||
// Render into status-content div, not the whole panel (preserves webhooks section)
|
||||
const contentDiv = document.getElementById('status-content');
|
||||
const panelCheck = document.getElementById('status-panel');
|
||||
console.log('[Status] contentDiv found:', !!contentDiv, 'panel children:', panelCheck?.children?.length, 'HTML length:', html.length);
|
||||
if (panelCheck) {
|
||||
console.log('[Status] panel innerHTML preview:', panelCheck.innerHTML.substring(0, 200));
|
||||
}
|
||||
if (contentDiv) {
|
||||
contentDiv.innerHTML = html;
|
||||
console.log('[Status] HTML rendered, contentDiv innerHTML length:', contentDiv.innerHTML.length);
|
||||
} else {
|
||||
console.error('[Status] contentDiv not found!');
|
||||
}
|
||||
// Wire close button — addEventListener avoids CSP inline handler restrictions
|
||||
const closeBtn = document.getElementById('status-close-btn');
|
||||
if (closeBtn) closeBtn.addEventListener('click', closeStatusPanel);
|
||||
// Set bar widths via JS DOM assignment — immune to CSP style-src restrictions
|
||||
panel.querySelectorAll('.timing-bar[data-w]').forEach(el => {
|
||||
el.style.width = el.dataset.w + '%';
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
50
client/src/ui/tabs.js
Normal file
50
client/src/ui/tabs.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { getActiveTab, saveActiveTab } from '../utils/storage.js';
|
||||
import { loadHistory } from './history.js';
|
||||
|
||||
export function initTabs() {
|
||||
const downloadsTab = document.getElementById('downloads-tab');
|
||||
const historyTab = document.getElementById('history-tab');
|
||||
const downloadsSection = document.getElementById('downloads-section');
|
||||
const historySection = document.getElementById('history-section');
|
||||
|
||||
if (!downloadsTab || !historyTab) return;
|
||||
|
||||
// Load saved tab
|
||||
const savedTab = getActiveTab();
|
||||
if (savedTab === 'history') {
|
||||
activateTab('history');
|
||||
} else {
|
||||
activateTab('downloads');
|
||||
}
|
||||
|
||||
downloadsTab.addEventListener('click', () => activateTab('downloads'));
|
||||
historyTab.addEventListener('click', () => activateTab('history'));
|
||||
}
|
||||
|
||||
export function activateTab(tab) {
|
||||
const downloadsTab = document.getElementById('downloads-tab');
|
||||
const historyTab = document.getElementById('history-tab');
|
||||
const downloadsSection = document.getElementById('downloads-section');
|
||||
const historySection = document.getElementById('history-section');
|
||||
|
||||
if (tab === 'downloads') {
|
||||
downloadsTab.classList.add('active');
|
||||
historyTab.classList.remove('active');
|
||||
downloadsSection.style.display = 'block';
|
||||
historySection.style.display = 'none';
|
||||
saveActiveTab('downloads');
|
||||
} else if (tab === 'history') {
|
||||
historyTab.classList.add('active');
|
||||
downloadsTab.classList.remove('active');
|
||||
historySection.style.display = 'block';
|
||||
downloadsSection.style.display = 'none';
|
||||
saveActiveTab('history');
|
||||
loadHistory();
|
||||
}
|
||||
}
|
||||
|
||||
export function goHome() {
|
||||
activateTab('downloads');
|
||||
}
|
||||
27
client/src/ui/theme.js
Normal file
27
client/src/ui/theme.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { getTheme, saveTheme } from '../utils/storage.js';
|
||||
|
||||
// Apply saved theme immediately on load
|
||||
(function applyTheme() {
|
||||
const theme = getTheme();
|
||||
if (theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
})();
|
||||
|
||||
export function initThemeSwitcher() {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (!themeToggle) return;
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const currentTheme = getTheme();
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
setTheme(newTheme);
|
||||
});
|
||||
}
|
||||
|
||||
export function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
saveTheme(theme);
|
||||
}
|
||||
192
client/src/ui/webhooks.js
Normal file
192
client/src/ui/webhooks.js
Normal file
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state } from '../state.js';
|
||||
import { fetchWebhookStatus as apiFetchWebhookStatus, enableSonarrWebhook as apiEnableSonarrWebhook, enableRadarrWebhook as apiEnableRadarrWebhook, testSonarrWebhook as apiTestSonarrWebhook, testRadarrWebhook as apiTestRadarrWebhook } from '../api.js';
|
||||
import { formatTimeAgo } from '../utils/format.js';
|
||||
|
||||
export function initWebhooks() {
|
||||
const webhooksSection = document.getElementById('webhooks-section');
|
||||
if (!webhooksSection) return;
|
||||
|
||||
// Note: visibility is controlled by showDashboard() based on isAdmin
|
||||
|
||||
document.getElementById('webhooks-header').addEventListener('click', toggleWebhookSection);
|
||||
document.getElementById('enable-sonarr-webhook').addEventListener('click', enableSonarrWebhook);
|
||||
document.getElementById('enable-radarr-webhook').addEventListener('click', enableRadarrWebhook);
|
||||
document.getElementById('test-sonarr-webhook').addEventListener('click', testSonarrWebhook);
|
||||
document.getElementById('test-radarr-webhook').addEventListener('click', testRadarrWebhook);
|
||||
}
|
||||
|
||||
export function toggleWebhookSection() {
|
||||
state.webhookSectionExpanded = !state.webhookSectionExpanded;
|
||||
const content = document.getElementById('webhooks-content');
|
||||
const toggle = document.getElementById('webhooks-toggle');
|
||||
|
||||
content.style.display = state.webhookSectionExpanded ? '' : 'none';
|
||||
toggle.classList.toggle('expanded', state.webhookSectionExpanded);
|
||||
|
||||
if (state.webhookSectionExpanded) {
|
||||
fetchWebhookStatus();
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWebhookStatus() {
|
||||
const loadingEl = document.getElementById('webhook-loading');
|
||||
loadingEl.style.display = '';
|
||||
|
||||
try {
|
||||
const result = await apiFetchWebhookStatus();
|
||||
if (result.success) {
|
||||
renderWebhookStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch webhook status:', err);
|
||||
} finally {
|
||||
loadingEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
export function renderWebhookStatus() {
|
||||
// Sonarr
|
||||
const sonarrStatus = document.getElementById('sonarr-status');
|
||||
const sonarrEnableBtn = document.getElementById('enable-sonarr-webhook');
|
||||
const sonarrTestBtn = document.getElementById('test-sonarr-webhook');
|
||||
const sonarrTriggers = document.getElementById('sonarr-triggers');
|
||||
const sonarrStats = document.getElementById('sonarr-stats');
|
||||
|
||||
sonarrStatus.textContent = sonarrWebhook.enabled ? '● Enabled' : '○ Disabled';
|
||||
sonarrStatus.className = 'status-indicator ' + (sonarrWebhook.enabled ? 'enabled' : 'disabled');
|
||||
sonarrEnableBtn.style.display = sonarrWebhook.enabled ? 'none' : '';
|
||||
sonarrTestBtn.style.display = sonarrWebhook.enabled ? '' : 'none';
|
||||
sonarrTriggers.style.display = sonarrWebhook.enabled ? '' : 'none';
|
||||
|
||||
if (sonarrWebhook.enabled) {
|
||||
document.getElementById('sonarr-onGrab').textContent = sonarrWebhook.triggers.onGrab ? '✓' : '✗';
|
||||
document.getElementById('sonarr-onGrab').className = 'trigger-value ' + (sonarrWebhook.triggers.onGrab ? 'active' : 'inactive');
|
||||
document.getElementById('sonarr-onDownload').textContent = sonarrWebhook.triggers.onDownload ? '✓' : '✗';
|
||||
document.getElementById('sonarr-onDownload').className = 'trigger-value ' + (sonarrWebhook.triggers.onDownload ? 'active' : 'inactive');
|
||||
document.getElementById('sonarr-onImport').textContent = sonarrWebhook.triggers.onImport ? '✓' : '✗';
|
||||
document.getElementById('sonarr-onImport').className = 'trigger-value ' + (sonarrWebhook.triggers.onImport ? 'active' : 'inactive');
|
||||
document.getElementById('sonarr-onUpgrade').textContent = sonarrWebhook.triggers.onUpgrade ? '✓' : '✗';
|
||||
document.getElementById('sonarr-onUpgrade').className = 'trigger-value ' + (sonarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
|
||||
}
|
||||
|
||||
if (sonarrWebhook.stats) {
|
||||
sonarrStats.style.display = '';
|
||||
document.getElementById('sonarr-events').textContent = sonarrWebhook.stats.eventsReceived ?? 0;
|
||||
document.getElementById('sonarr-polls').textContent = sonarrWebhook.stats.pollsSkipped ?? 0;
|
||||
document.getElementById('sonarr-last').textContent = formatTimeAgo(sonarrWebhook.stats.lastWebhookTimestamp);
|
||||
} else {
|
||||
sonarrStats.style.display = 'none';
|
||||
}
|
||||
|
||||
// Radarr
|
||||
const radarrStatus = document.getElementById('radarr-status');
|
||||
const radarrEnableBtn = document.getElementById('enable-radarr-webhook');
|
||||
const radarrTestBtn = document.getElementById('test-radarr-webhook');
|
||||
const radarrTriggers = document.getElementById('radarr-triggers');
|
||||
const radarrStats = document.getElementById('radarr-stats');
|
||||
|
||||
radarrStatus.textContent = radarrWebhook.enabled ? '● Enabled' : '○ Disabled';
|
||||
radarrStatus.className = 'status-indicator ' + (radarrWebhook.enabled ? 'enabled' : 'disabled');
|
||||
radarrEnableBtn.style.display = radarrWebhook.enabled ? 'none' : '';
|
||||
radarrTestBtn.style.display = radarrWebhook.enabled ? '' : 'none';
|
||||
radarrTriggers.style.display = radarrWebhook.enabled ? '' : 'none';
|
||||
|
||||
if (radarrWebhook.enabled) {
|
||||
document.getElementById('radarr-onGrab').textContent = radarrWebhook.triggers.onGrab ? '✓' : '✗';
|
||||
document.getElementById('radarr-onGrab').className = 'trigger-value ' + (radarrWebhook.triggers.onGrab ? 'active' : 'inactive');
|
||||
document.getElementById('radarr-onDownload').textContent = radarrWebhook.triggers.onDownload ? '✓' : '✗';
|
||||
document.getElementById('radarr-onDownload').className = 'trigger-value ' + (radarrWebhook.triggers.onDownload ? 'active' : 'inactive');
|
||||
document.getElementById('radarr-onImport').textContent = radarrWebhook.triggers.onImport ? '✓' : '✗';
|
||||
document.getElementById('radarr-onImport').className = 'trigger-value ' + (radarrWebhook.triggers.onImport ? 'active' : 'inactive');
|
||||
document.getElementById('radarr-onUpgrade').textContent = radarrWebhook.triggers.onUpgrade ? '✓' : '✗';
|
||||
document.getElementById('radarr-onUpgrade').className = 'trigger-value ' + (radarrWebhook.triggers.onUpgrade ? 'active' : 'inactive');
|
||||
}
|
||||
|
||||
if (radarrWebhook.stats) {
|
||||
radarrStats.style.display = '';
|
||||
document.getElementById('radarr-events').textContent = radarrWebhook.stats.eventsReceived ?? 0;
|
||||
document.getElementById('radarr-polls').textContent = radarrWebhook.stats.pollsSkipped ?? 0;
|
||||
document.getElementById('radarr-last').textContent = formatTimeAgo(radarrWebhook.stats.lastWebhookTimestamp);
|
||||
} else {
|
||||
radarrStats.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableSonarrWebhook() {
|
||||
setWebhookLoading(true);
|
||||
try {
|
||||
const result = await apiEnableSonarrWebhook();
|
||||
if (!result.success) {
|
||||
console.error('Failed to enable Sonarr webhook:', result.error);
|
||||
alert('Failed to enable Sonarr webhook. Check console for details.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to enable Sonarr webhook:', err);
|
||||
alert('Failed to enable Sonarr webhook. Check console for details.');
|
||||
} finally {
|
||||
setWebhookLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableRadarrWebhook() {
|
||||
setWebhookLoading(true);
|
||||
try {
|
||||
const result = await apiEnableRadarrWebhook();
|
||||
if (!result.success) {
|
||||
console.error('Failed to enable Radarr webhook:', result.error);
|
||||
alert('Failed to enable Radarr webhook. Check console for details.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to enable Radarr webhook:', err);
|
||||
alert('Failed to enable Radarr webhook. Check console for details.');
|
||||
} finally {
|
||||
setWebhookLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSonarrWebhook() {
|
||||
setWebhookLoading(true);
|
||||
try {
|
||||
const result = await apiTestSonarrWebhook();
|
||||
if (result.success) {
|
||||
alert('Sonarr webhook test sent successfully!');
|
||||
} else {
|
||||
console.error('Failed to test Sonarr webhook:', result.error);
|
||||
alert('Failed to test Sonarr webhook. Check console for details.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to test Sonarr webhook:', err);
|
||||
alert('Failed to test Sonarr webhook. Check console for details.');
|
||||
} finally {
|
||||
setWebhookLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function testRadarrWebhook() {
|
||||
setWebhookLoading(true);
|
||||
try {
|
||||
const result = await apiTestRadarrWebhook();
|
||||
if (result.success) {
|
||||
alert('Radarr webhook test sent successfully!');
|
||||
} else {
|
||||
console.error('Failed to test Radarr webhook:', result.error);
|
||||
alert('Failed to test Radarr webhook. Check console for details.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to test Radarr webhook:', err);
|
||||
alert('Failed to test Radarr webhook. Check console for details.');
|
||||
} finally {
|
||||
setWebhookLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function setWebhookLoading(loading) {
|
||||
state.webhookLoading = loading;
|
||||
document.getElementById('enable-sonarr-webhook').disabled = loading;
|
||||
document.getElementById('enable-radarr-webhook').disabled = loading;
|
||||
document.getElementById('test-sonarr-webhook').disabled = loading;
|
||||
document.getElementById('test-radarr-webhook').disabled = loading;
|
||||
document.getElementById('webhook-loading').style.display = loading ? '' : 'none';
|
||||
}
|
||||
74
client/src/utils/format.js
Normal file
74
client/src/utils/format.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
export 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];
|
||||
}
|
||||
|
||||
export function formatSpeed(bytesPerSecond) {
|
||||
if (!bytesPerSecond || bytesPerSecond === 0) return '0 B/s';
|
||||
|
||||
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||
let value = bytesPerSecond;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${value.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
|
||||
export function formatTimeAgo(timestamp) {
|
||||
if (!timestamp) return 'Never';
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 60) return seconds + 's ago';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return minutes + 'm ago';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return hours + 'h ago';
|
||||
return Math.floor(hours / 24) + 'd ago';
|
||||
}
|
||||
|
||||
export function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Build an episode-info element for series downloads/history.
|
||||
// Single episode: "S01E05 — Episode Title"
|
||||
// Multiple episodes: "Multiple episodes" with tooltip listing them all.
|
||||
// Returns null if no episode data.
|
||||
export function formatEpisodeInfo(episodes) {
|
||||
if (!episodes || episodes.length === 0) return null;
|
||||
const el = document.createElement('p');
|
||||
el.className = 'episode-info';
|
||||
if (episodes.length === 1) {
|
||||
const ep = episodes[0];
|
||||
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
|
||||
el.textContent = ep.title ? code + ' \u2014 ' + ep.title : code;
|
||||
} else {
|
||||
el.textContent = 'Multiple episodes';
|
||||
el.classList.add('multi-episode');
|
||||
const lines = episodes.map(ep => {
|
||||
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
|
||||
return ep.title ? code + ' \u2014 ' + ep.title : code;
|
||||
});
|
||||
el.setAttribute('data-tooltip', lines.join('\n'));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
76
client/src/utils/storage.js
Normal file
76
client/src/utils/storage.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
import { state } from '../state.js';
|
||||
|
||||
// Migration from old single-select to new multi-select format
|
||||
(function migrateDownloadClientFilter() {
|
||||
const oldSelection = localStorage.getItem('sofarr-download-client');
|
||||
if (oldSelection && oldSelection !== 'all') {
|
||||
try {
|
||||
state.selectedDownloadClients = [oldSelection];
|
||||
localStorage.setItem('sofarr-download-clients', JSON.stringify(state.selectedDownloadClients));
|
||||
localStorage.removeItem('sofarr-download-client');
|
||||
} catch (e) {
|
||||
console.error('[Migration] Failed to migrate download client filter:', e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const newSelection = localStorage.getItem('sofarr-download-clients');
|
||||
state.selectedDownloadClients = newSelection ? JSON.parse(newSelection) : [];
|
||||
} catch (e) {
|
||||
console.error('[Migration] Failed to load download client filter:', e);
|
||||
state.selectedDownloadClients = [];
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// Load history days from localStorage
|
||||
(function loadHistorySettings() {
|
||||
try {
|
||||
const savedDays = localStorage.getItem('sofarr-history-days');
|
||||
if (savedDays) {
|
||||
state.historyDays = parseInt(savedDays, 10) || 7;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Storage] Failed to load history days:', e);
|
||||
}
|
||||
})();
|
||||
|
||||
// Load ignore available setting from localStorage
|
||||
(function loadIgnoreAvailable() {
|
||||
try {
|
||||
const saved = localStorage.getItem('sofarr-ignore-available');
|
||||
state.ignoreAvailable = saved === 'true';
|
||||
} catch (e) {
|
||||
console.error('[Storage] Failed to load ignore available:', e);
|
||||
}
|
||||
})();
|
||||
|
||||
// Export helper functions for localStorage operations
|
||||
export function saveHistoryDays(days) {
|
||||
localStorage.setItem('sofarr-history-days', days);
|
||||
}
|
||||
|
||||
export function saveIgnoreAvailable(value) {
|
||||
localStorage.setItem('sofarr-ignore-available', value);
|
||||
}
|
||||
|
||||
export function saveDownloadClients(clients) {
|
||||
localStorage.setItem('sofarr-download-clients', JSON.stringify(clients));
|
||||
}
|
||||
|
||||
export function getTheme() {
|
||||
return localStorage.getItem('sofarr-theme') || 'light';
|
||||
}
|
||||
|
||||
export function saveTheme(theme) {
|
||||
localStorage.setItem('sofarr-theme', theme);
|
||||
}
|
||||
|
||||
export function getActiveTab() {
|
||||
return localStorage.getItem('sofarr-active-tab') || 'downloads';
|
||||
}
|
||||
|
||||
export function saveActiveTab(tab) {
|
||||
localStorage.setItem('sofarr-active-tab', tab);
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: '../public',
|
||||
emptyOutDir: false,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: './src/main.js'
|
||||
},
|
||||
output: {
|
||||
entryFileNames: 'app.js',
|
||||
chunkFileNames: '[name].js',
|
||||
assetFileNames: '[name][extname]'
|
||||
}
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
1885
public/app.js
1885
public/app.js
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user