Merge branch 'develop'
This commit is contained in:
@@ -459,3 +459,41 @@ body {
|
|||||||
.trigger-value.inactive {
|
.trigger-value.inactive {
|
||||||
color: #999;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ function App() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [sessions, setSessions] = useState([]);
|
const [sessions, setSessions] = useState([]);
|
||||||
const [webhookSectionExpanded, setWebhookSectionExpanded] = useState(false);
|
const [webhookSectionExpanded, setWebhookSectionExpanded] = useState(false);
|
||||||
const [sonarrWebhook, setSonarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: 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 } });
|
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);
|
const [webhookLoading, setWebhookLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -72,43 +73,82 @@ function App() {
|
|||||||
return new Date(dateString).toLocaleString();
|
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 () => {
|
const fetchWebhookStatus = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Fetch metrics in parallel with notification status
|
||||||
|
const metricsPromise = fetchWebhookMetrics();
|
||||||
|
|
||||||
// Fetch Sonarr notifications
|
// Fetch Sonarr notifications
|
||||||
|
let sonarrEnabled = false;
|
||||||
|
let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
||||||
try {
|
try {
|
||||||
const sonarrResponse = await axios.get('/api/sonarr/notifications');
|
const sonarrResponse = await axios.get('/api/sonarr/notifications');
|
||||||
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
|
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
|
||||||
setSonarrWebhook({
|
sonarrEnabled = !!sonarrSofarr;
|
||||||
enabled: !!sonarrSofarr,
|
if (sonarrSofarr) {
|
||||||
triggers: sonarrSofarr ? {
|
sonarrTriggers = {
|
||||||
onGrab: sonarrSofarr.onGrab,
|
onGrab: sonarrSofarr.onGrab,
|
||||||
onDownload: sonarrSofarr.onDownload,
|
onDownload: sonarrSofarr.onDownload,
|
||||||
onImport: sonarrSofarr.onImport,
|
onImport: sonarrSofarr.onImport,
|
||||||
onUpgrade: sonarrSofarr.onUpgrade
|
onUpgrade: sonarrSofarr.onUpgrade
|
||||||
} : { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }
|
};
|
||||||
});
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Sonarr not configured or not accessible
|
// Sonarr not configured or not accessible
|
||||||
setSonarrWebhook({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch Radarr notifications
|
// Fetch Radarr notifications
|
||||||
|
let radarrEnabled = false;
|
||||||
|
let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false };
|
||||||
try {
|
try {
|
||||||
const radarrResponse = await axios.get('/api/radarr/notifications');
|
const radarrResponse = await axios.get('/api/radarr/notifications');
|
||||||
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
|
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
|
||||||
setRadarrWebhook({
|
radarrEnabled = !!radarrSofarr;
|
||||||
enabled: !!radarrSofarr,
|
if (radarrSofarr) {
|
||||||
triggers: radarrSofarr ? {
|
radarrTriggers = {
|
||||||
onGrab: radarrSofarr.onGrab,
|
onGrab: radarrSofarr.onGrab,
|
||||||
onDownload: radarrSofarr.onDownload,
|
onDownload: radarrSofarr.onDownload,
|
||||||
onImport: radarrSofarr.onImport,
|
onImport: radarrSofarr.onImport,
|
||||||
onUpgrade: radarrSofarr.onUpgrade
|
onUpgrade: radarrSofarr.onUpgrade
|
||||||
} : { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }
|
};
|
||||||
});
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Radarr not configured or not accessible
|
// Radarr not configured or not accessible
|
||||||
setRadarrWebhook({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch webhook status:', err);
|
console.error('Failed to fetch webhook status:', err);
|
||||||
}
|
}
|
||||||
@@ -147,6 +187,7 @@ function App() {
|
|||||||
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
|
const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr');
|
||||||
if (sonarrSofarr) {
|
if (sonarrSofarr) {
|
||||||
await axios.post('/api/sonarr/notifications/test', { id: sonarrSofarr.id });
|
await axios.post('/api/sonarr/notifications/test', { id: sonarrSofarr.id });
|
||||||
|
await fetchWebhookStatus();
|
||||||
alert('Sonarr webhook test sent successfully!');
|
alert('Sonarr webhook test sent successfully!');
|
||||||
} else {
|
} else {
|
||||||
alert('Sofarr webhook not configured for Sonarr.');
|
alert('Sofarr webhook not configured for Sonarr.');
|
||||||
@@ -166,6 +207,7 @@ function App() {
|
|||||||
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
|
const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr');
|
||||||
if (radarrSofarr) {
|
if (radarrSofarr) {
|
||||||
await axios.post('/api/radarr/notifications/test', { id: radarrSofarr.id });
|
await axios.post('/api/radarr/notifications/test', { id: radarrSofarr.id });
|
||||||
|
await fetchWebhookStatus();
|
||||||
alert('Radarr webhook test sent successfully!');
|
alert('Radarr webhook test sent successfully!');
|
||||||
} else {
|
} else {
|
||||||
alert('Sofarr webhook not configured for Radarr.');
|
alert('Sofarr webhook not configured for Radarr.');
|
||||||
@@ -342,6 +384,25 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div className="webhook-instance">
|
<div className="webhook-instance">
|
||||||
<h3>Radarr</h3>
|
<h3>Radarr</h3>
|
||||||
@@ -388,6 +449,25 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.5.1",
|
"version": "1.5.2",
|
||||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
1200
public/app.js
1200
public/app.js
File diff suppressed because one or more lines are too long
@@ -1,126 +1,14 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>sofarr - Your Downloads Dashboard</title>
|
<title>Media Download Dashboard</title>
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<script type="module" crossorigin src="app.js"></script>
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
|
||||||
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
|
|
||||||
<meta name="theme-color" content="#1a1a2e">
|
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Splash Screen -->
|
<div id="root"></div>
|
||||||
<div id="splash-screen" class="splash-screen">
|
|
||||||
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="splash-logo">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="app">
|
|
||||||
<!-- Login Form -->
|
|
||||||
<div id="login-container" class="login-container" style="display: none;">
|
|
||||||
<div class="login-box">
|
|
||||||
<img src="images/sofarr-flashscreen.png" alt="sofarr" class="login-logo">
|
|
||||||
<p class="login-subtitle">Login with your Emby credentials</p>
|
|
||||||
<form id="login-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="username">Username:</label>
|
|
||||||
<input type="text" id="username" name="username" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="password">Password:</label>
|
|
||||||
<input type="password" id="password" name="password" required>
|
|
||||||
</div>
|
|
||||||
<div class="form-group form-group--checkbox">
|
|
||||||
<label class="checkbox-label">
|
|
||||||
<input type="checkbox" id="remember-me" name="rememberMe">
|
|
||||||
<span>Keep me logged in</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="login-btn">Login</button>
|
|
||||||
</form>
|
|
||||||
<div id="login-error" class="error-message" style="display: none;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dashboard -->
|
|
||||||
<div id="dashboard-container" class="dashboard-container" style="display: none;">
|
|
||||||
<header class="app-header">
|
|
||||||
<h1><a href="#" class="title-link" id="title-home-link"><img src="favicon-192.png" alt="" class="title-logo">sofarr</a></h1>
|
|
||||||
<div class="header-controls">
|
|
||||||
<div class="theme-switcher">
|
|
||||||
<button class="theme-btn active" data-theme="light">Light</button>
|
|
||||||
<button class="theme-btn" data-theme="dark">Dark</button>
|
|
||||||
<button class="theme-btn" data-theme="mono">Mono</button>
|
|
||||||
</div>
|
|
||||||
<div id="admin-controls" class="admin-controls" style="display: none;">
|
|
||||||
<label class="toggle-label">
|
|
||||||
<input type="checkbox" id="show-all-toggle">
|
|
||||||
<span>Show all users</span>
|
|
||||||
</label>
|
|
||||||
<button id="status-btn" class="status-btn">Status</button>
|
|
||||||
</div>
|
|
||||||
<div class="user-info">
|
|
||||||
<span class="user-label">Current User:</span>
|
|
||||||
<span class="user-name" id="currentUser">-</span>
|
|
||||||
<button id="logout-btn" class="logout-btn">Logout</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div id="status-panel" class="status-panel" style="display: none;"></div>
|
|
||||||
|
|
||||||
<div id="error-message" class="error-message" style="display: none;"></div>
|
|
||||||
|
|
||||||
<div id="loading" class="loading" style="display: none;">Loading downloads...</div>
|
|
||||||
|
|
||||||
<div class="main-tabs">
|
|
||||||
<div class="tab-bar">
|
|
||||||
<button class="tab-btn active" data-tab="downloads">Active Downloads</button>
|
|
||||||
<button class="tab-btn" data-tab="history">Recently Completed</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-panel" id="tab-downloads">
|
|
||||||
<div class="downloads-container">
|
|
||||||
<div id="no-downloads" class="no-downloads" style="display: none;">
|
|
||||||
<p>No downloads found for your user.</p>
|
|
||||||
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>
|
|
||||||
</div>
|
|
||||||
<div id="downloads-list" class="downloads-list"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-panel" id="tab-history" style="display: none;">
|
|
||||||
<div class="history-container" id="history-container">
|
|
||||||
<div class="history-header">
|
|
||||||
<div class="history-controls">
|
|
||||||
<label class="history-days-label" for="history-days">Last</label>
|
|
||||||
<input type="number" id="history-days" class="history-days-input" value="7" min="1" max="90">
|
|
||||||
<span class="history-days-label">days</span>
|
|
||||||
<button id="history-refresh-btn" class="history-refresh-btn" title="Refresh history">↻</button>
|
|
||||||
<label class="history-toggle-label" id="ignore-available-label" data-tooltip="Hide failed downloads where the item is already available on disk (i.e. a failed upgrade attempt)">
|
|
||||||
<input type="checkbox" id="ignore-available-toggle">
|
|
||||||
<span>Hide upgrade failures</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="history-loading" class="history-loading" style="display: none;">Loading history...</div>
|
|
||||||
<div id="history-error" class="history-error" style="display: none;"></div>
|
|
||||||
<div id="no-history" class="no-history" style="display: none;">
|
|
||||||
<p>No completed downloads found in this period.</p>
|
|
||||||
</div>
|
|
||||||
<div id="history-list" class="history-list"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="app-footer">
|
|
||||||
<p>Ensure your media is tagged with your username in Sonarr/Radarr to match downloads to users.</p>
|
|
||||||
<a href="https://git.i3omb.com/Gandalf/sofarr" target="_blank" rel="noopener noreferrer" class="app-version" id="app-version"></a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="app.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
1519
public/style.css
1519
public/style.css
File diff suppressed because one or more lines are too long
@@ -803,6 +803,17 @@ router.get('/status', requireAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Webhook metrics — exposes global and per-instance webhook metrics for the
|
||||||
|
// Webhooks Configuration panel. Available to all authenticated users.
|
||||||
|
router.get('/webhook-metrics', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { getGlobalWebhookMetrics } = require('../utils/cache');
|
||||||
|
res.json(getGlobalWebhookMetrics());
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to get webhook metrics', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Cover art proxy — fetches external poster images server-side so the
|
// Cover art proxy — fetches external poster images server-side so the
|
||||||
// browser loads them from 'self' and the CSP img-src stays tight.
|
// browser loads them from 'self' and the CSP img-src stays tight.
|
||||||
// Requires authentication. Only proxies http/https URLs.
|
// Requires authentication. Only proxies http/https URLs.
|
||||||
|
|||||||
Reference in New Issue
Block a user