diff --git a/.env.sample b/.env.sample index dd41483..fc95ae1 100644 --- a/.env.sample +++ b/.env.sample @@ -29,6 +29,12 @@ COOKIE_SECRET=your-cookie-secret-here # Generate with: openssl rand -hex 32 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 # ============================================================================= diff --git a/server/routes/radarr.js b/server/routes/radarr.js index 6d74211..fcfc6e5 100644 --- a/server/routes/radarr.js +++ b/server/routes/radarr.js @@ -4,6 +4,7 @@ const axios = require('axios'); const router = express.Router(); const requireAuth = require('../middleware/requireAuth'); const sanitizeError = require('../utils/sanitizeError'); +const { getWebhookSecret, getSofarrBaseUrl } = require('../utils/config'); 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; diff --git a/server/routes/sonarr.js b/server/routes/sonarr.js index aba4bdc..9e4d4a4 100644 --- a/server/routes/sonarr.js +++ b/server/routes/sonarr.js @@ -4,6 +4,7 @@ const axios = require('axios'); const router = express.Router(); const requireAuth = require('../middleware/requireAuth'); const sanitizeError = require('../utils/sanitizeError'); +const { getWebhookSecret, getSofarrBaseUrl } = require('../utils/config'); 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; diff --git a/server/utils/config.js b/server/utils/config.js index 1d95e80..58c2c66 100644 --- a/server/utils/config.js +++ b/server/utils/config.js @@ -118,6 +118,10 @@ function getWebhookSecret() { return process.env.SOFARR_WEBHOOK_SECRET || ''; } +function getSofarrBaseUrl() { + return process.env.SOFARR_BASE_URL || ''; +} + module.exports = { getSABnzbdInstances, getSonarrInstances, @@ -126,6 +130,7 @@ module.exports = { getTransmissionInstances, getRtorrentInstances, getWebhookSecret, + getSofarrBaseUrl, parseInstances, validateInstanceUrl };