feat(webhooks): add notification management API + one-click Sofarr webhook setup (Phase 3)
All checks were successful
All checks were successful
This commit is contained in:
@@ -29,6 +29,12 @@ COOKIE_SECRET=your-cookie-secret-here
|
|||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
SOFARR_WEBHOOK_SECRET=your-webhook-secret-here
|
SOFARR_WEBHOOK_SECRET=your-webhook-secret-here
|
||||||
|
|
||||||
|
# Public base URL of Sofarr (for webhook configuration)
|
||||||
|
# Required for the one-click webhook setup endpoints
|
||||||
|
# Sonarr/Radarr need this URL to know where to send webhook events
|
||||||
|
# Example: https://sofarr.example.com or https://192.168.1.100:3001
|
||||||
|
SOFARR_BASE_URL=https://your-sofarr-url
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# TLS / HTTPS
|
# TLS / HTTPS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const axios = require('axios');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
|
const { getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
@@ -56,4 +57,150 @@ router.get('/movies', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notification proxy routes (Phase 3)
|
||||||
|
// GET /api/radarr/notifications - list all notifications
|
||||||
|
router.get('/notifications', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification`, {
|
||||||
|
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Radarr notifications', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/radarr/notifications/:id - get specific notification
|
||||||
|
router.get('/notifications/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, {
|
||||||
|
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Radarr notification', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/radarr/notifications - create notification
|
||||||
|
router.post('/notifications', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${process.env.RADARR_URL}/api/v3/notification`, req.body, {
|
||||||
|
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to create Radarr notification', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/radarr/notifications/:id - update notification
|
||||||
|
router.put('/notifications/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.put(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, req.body, {
|
||||||
|
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to update Radarr notification', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/radarr/notifications/:id - delete notification
|
||||||
|
router.delete('/notifications/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.delete(`${process.env.RADARR_URL}/api/v3/notification/${req.params.id}`, {
|
||||||
|
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to delete Radarr notification', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/radarr/notifications/test - test notification
|
||||||
|
router.post('/notifications/test', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${process.env.RADARR_URL}/api/v3/notification/test`, req.body, {
|
||||||
|
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to test Radarr notification', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/radarr/notifications/schema - get notification schema
|
||||||
|
router.get('/notifications/schema', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${process.env.RADARR_URL}/api/v3/notification/schema`, {
|
||||||
|
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Radarr notification schema', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/radarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
|
||||||
|
router.post('/notifications/sofarr-webhook', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||||
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
|
if (!sofarrBaseUrl) {
|
||||||
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
|
}
|
||||||
|
if (!webhookSecret) {
|
||||||
|
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`;
|
||||||
|
|
||||||
|
// Check if Sofarr webhook already exists
|
||||||
|
const listResponse = await axios.get(`${process.env.RADARR_URL}/api/v3/notification`, {
|
||||||
|
headers: { 'X-Api-Key': process.env.RADARR_API_KEY }
|
||||||
|
});
|
||||||
|
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
|
||||||
|
|
||||||
|
const notificationPayload = {
|
||||||
|
name: 'Sofarr',
|
||||||
|
implementation: 'Webhook',
|
||||||
|
configContract: 'WebhookSettings',
|
||||||
|
fields: [
|
||||||
|
{ name: 'url', value: webhookUrl },
|
||||||
|
{ name: 'method', value: 'POST' },
|
||||||
|
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
|
||||||
|
],
|
||||||
|
onGrab: true,
|
||||||
|
onDownload: true,
|
||||||
|
onImport: true,
|
||||||
|
onUpgrade: true,
|
||||||
|
onRename: false,
|
||||||
|
onHealthIssue: false,
|
||||||
|
onApplicationUpdate: false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingNotification) {
|
||||||
|
// Update existing notification
|
||||||
|
const response = await axios.put(
|
||||||
|
`${process.env.RADARR_URL}/api/v3/notification/${existingNotification.id}`,
|
||||||
|
{ ...notificationPayload, id: existingNotification.id },
|
||||||
|
{ headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }
|
||||||
|
);
|
||||||
|
res.json(response.data);
|
||||||
|
} else {
|
||||||
|
// Create new notification
|
||||||
|
const response = await axios.post(
|
||||||
|
`${process.env.RADARR_URL}/api/v3/notification`,
|
||||||
|
notificationPayload,
|
||||||
|
{ headers: { 'X-Api-Key': process.env.RADARR_API_KEY } }
|
||||||
|
);
|
||||||
|
res.json(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const axios = require('axios');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
const sanitizeError = require('../utils/sanitizeError');
|
||||||
|
const { getWebhookSecret, getSofarrBaseUrl } = require('../utils/config');
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
@@ -56,4 +57,150 @@ router.get('/series', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notification proxy routes (Phase 3)
|
||||||
|
// GET /api/sonarr/notifications - list all notifications
|
||||||
|
router.get('/notifications', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification`, {
|
||||||
|
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Sonarr notifications', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/sonarr/notifications/:id - get specific notification
|
||||||
|
router.get('/notifications/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, {
|
||||||
|
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Sonarr notification', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/sonarr/notifications - create notification
|
||||||
|
router.post('/notifications', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${process.env.SONARR_URL}/api/v3/notification`, req.body, {
|
||||||
|
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to create Sonarr notification', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/sonarr/notifications/:id - update notification
|
||||||
|
router.put('/notifications/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.put(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, req.body, {
|
||||||
|
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to update Sonarr notification', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/sonarr/notifications/:id - delete notification
|
||||||
|
router.delete('/notifications/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.delete(`${process.env.SONARR_URL}/api/v3/notification/${req.params.id}`, {
|
||||||
|
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to delete Sonarr notification', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/sonarr/notifications/test - test notification
|
||||||
|
router.post('/notifications/test', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${process.env.SONARR_URL}/api/v3/notification/test`, req.body, {
|
||||||
|
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to test Sonarr notification', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/sonarr/notifications/schema - get notification schema
|
||||||
|
router.get('/notifications/schema', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${process.env.SONARR_URL}/api/v3/notification/schema`, {
|
||||||
|
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||||
|
});
|
||||||
|
res.json(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Sonarr notification schema', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/sonarr/notifications/sofarr-webhook - one-click Sofarr webhook setup
|
||||||
|
router.post('/notifications/sofarr-webhook', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sofarrBaseUrl = getSofarrBaseUrl();
|
||||||
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
|
if (!sofarrBaseUrl) {
|
||||||
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
|
}
|
||||||
|
if (!webhookSecret) {
|
||||||
|
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`;
|
||||||
|
|
||||||
|
// Check if Sofarr webhook already exists
|
||||||
|
const listResponse = await axios.get(`${process.env.SONARR_URL}/api/v3/notification`, {
|
||||||
|
headers: { 'X-Api-Key': process.env.SONARR_API_KEY }
|
||||||
|
});
|
||||||
|
const existingNotification = listResponse.data.find(n => n.name === 'Sofarr');
|
||||||
|
|
||||||
|
const notificationPayload = {
|
||||||
|
name: 'Sofarr',
|
||||||
|
implementation: 'Webhook',
|
||||||
|
configContract: 'WebhookSettings',
|
||||||
|
fields: [
|
||||||
|
{ name: 'url', value: webhookUrl },
|
||||||
|
{ name: 'method', value: 'POST' },
|
||||||
|
{ name: 'headers', value: [{ key: 'X-Sofarr-Webhook-Secret', value: webhookSecret }] }
|
||||||
|
],
|
||||||
|
onGrab: true,
|
||||||
|
onDownload: true,
|
||||||
|
onImport: true,
|
||||||
|
onUpgrade: true,
|
||||||
|
onRename: false,
|
||||||
|
onHealthIssue: false,
|
||||||
|
onApplicationUpdate: false
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingNotification) {
|
||||||
|
// Update existing notification
|
||||||
|
const response = await axios.put(
|
||||||
|
`${process.env.SONARR_URL}/api/v3/notification/${existingNotification.id}`,
|
||||||
|
{ ...notificationPayload, id: existingNotification.id },
|
||||||
|
{ headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }
|
||||||
|
);
|
||||||
|
res.json(response.data);
|
||||||
|
} else {
|
||||||
|
// Create new notification
|
||||||
|
const response = await axios.post(
|
||||||
|
`${process.env.SONARR_URL}/api/v3/notification`,
|
||||||
|
notificationPayload,
|
||||||
|
{ headers: { 'X-Api-Key': process.env.SONARR_API_KEY } }
|
||||||
|
);
|
||||||
|
res.json(response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to configure Sofarr webhook', details: sanitizeError(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -118,6 +118,10 @@ function getWebhookSecret() {
|
|||||||
return process.env.SOFARR_WEBHOOK_SECRET || '';
|
return process.env.SOFARR_WEBHOOK_SECRET || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSofarrBaseUrl() {
|
||||||
|
return process.env.SOFARR_BASE_URL || '';
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getSABnzbdInstances,
|
getSABnzbdInstances,
|
||||||
getSonarrInstances,
|
getSonarrInstances,
|
||||||
@@ -126,6 +130,7 @@ module.exports = {
|
|||||||
getTransmissionInstances,
|
getTransmissionInstances,
|
||||||
getRtorrentInstances,
|
getRtorrentInstances,
|
||||||
getWebhookSecret,
|
getWebhookSecret,
|
||||||
|
getSofarrBaseUrl,
|
||||||
parseInstances,
|
parseInstances,
|
||||||
validateInstanceUrl
|
validateInstanceUrl
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user