const { APP_PORTS } = require('../../constants'); module.exports = function(ctx) { // Helper: Extract API key from arr service config.xml async function getArrApiKey(containerName) { try { const container = await ctx.docker.findContainer(containerName); if (!container) return null; const dockerContainer = ctx.docker.client.getContainer(container.Id); const exec = await dockerContainer.exec({ Cmd: ['cat', '/config/config.xml'], AttachStdout: true, AttachStderr: true }); const stream = await exec.start(); return new Promise((resolve) => { let data = ''; stream.on('data', chunk => data += chunk.toString()); stream.on('end', () => { // Extract API key from XML const match = data.match(/([^<]+)<\/ApiKey>/); resolve(match ? match[1] : null); }); stream.on('error', () => resolve(null)); }); } catch (error) { ctx.log.error('docker', 'Failed to get API key', { containerName, error: error.message }); return null; } } // Helper: Get Plex token from container or config async function getPlexToken(containerName) { try { const containers = await ctx.docker.client.listContainers({ all: false }); const container = containers.find(c => c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex')) ); if (!container) return null; const dockerContainer = ctx.docker.client.getContainer(container.Id); const exec = await dockerContainer.exec({ Cmd: ['cat', '/config/Library/Application Support/Plex Media Server/Preferences.xml'], AttachStdout: true, AttachStderr: true }); const stream = await exec.start(); return new Promise((resolve) => { let data = ''; stream.on('data', chunk => data += chunk.toString()); stream.on('end', () => { const match = data.match(/PlexOnlineToken="([^"]+)"/); resolve(match ? match[1] : null); }); stream.on('error', () => resolve(null)); }); } catch (error) { ctx.log.error('docker', 'Failed to get Plex token', { error: error.message }); return null; } } // Helper: Get container URL (internal Docker network or host) function getServiceUrl(containerName, port, useTailscale = false) { // For Docker containers, use localhost since they're on the same host const host = useTailscale ? (process.env.HOST_TAILSCALE_IP || 'localhost') : 'localhost'; return `http://${host}:${port}`; } // Helper: Get authenticated Seerr/Overseerr session via Plex token // Seerr requires Plex-based auth for admin endpoints (settings, configuration) async function getOverseerrSession() { const seerrUrl = `http://host.docker.internal:${APP_PORTS.seerr}`; try { // Try getting Plex token from running container first let plexToken = await getPlexToken('plex'); // Fall back to stored Plex token in credential manager if (!plexToken) { plexToken = await ctx.credentialManager.retrieve('arr.plex.token'); } if (!plexToken) { ctx.log.error('arr', 'Could not get Plex token for Seerr auth (no container, no stored token)'); return null; } // Authenticate with Seerr via Plex token const authRes = await ctx.fetchT(`${seerrUrl}/api/v1/auth/plex`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ authToken: plexToken }), signal: AbortSignal.timeout(10000) }); if (!authRes.ok) { ctx.log.error('arr', 'Seerr Plex auth failed', { status: authRes.status }); return null; } const setCookie = authRes.headers.get('set-cookie'); if (!setCookie) { ctx.log.error('arr', 'No session cookie returned from Seerr'); return null; } const sessionCookie = setCookie.split(';')[0]; return { cookie: sessionCookie, plexToken }; } catch (e) { ctx.log.error('arr', 'Could not get Seerr session', { error: e.message }); return null; } } // Helper: Connect Plex to Overseerr // Uses session cookie auth (Overseerr requires Plex-based admin session for settings) async function connectPlexToOverseerr(plexUrl, plexToken, overseerrUrl, sessionCookie) { // 1. Get Plex server identity (for return info) const identityRes = await ctx.fetchT(`${plexUrl}/identity`, { headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000) }); if (!identityRes.ok) throw new Error('Cannot reach Plex server'); const identity = await identityRes.json(); const serverName = identity.MediaContainer?.friendlyName || 'Plex'; // 2. Configure Plex server connection in Overseerr // Only send writable fields — name, machineId, libraries are read-only (auto-discovered by Overseerr) const plexConfig = { ip: 'host.docker.internal', port: APP_PORTS.plex, useSsl: false }; const configRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cookie': sessionCookie }, body: JSON.stringify(plexConfig) }); if (!configRes.ok) { throw new Error(`Overseerr Plex config failed: ${await configRes.text()}`); } // 3. Trigger library sync — Overseerr will use the admin's Plex token to discover libraries try { await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex/sync`, { method: 'POST', headers: { 'Cookie': sessionCookie }, signal: AbortSignal.timeout(10000) }); } catch (e) { ctx.log.warn('arr', 'Plex library sync trigger failed (non-fatal)', { error: e.message }); } // 4. Get discovered libraries let libraries = []; try { const libRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, { headers: { 'Cookie': sessionCookie }, signal: AbortSignal.timeout(5000) }); if (libRes.ok) { const plexSettings = await libRes.json(); libraries = plexSettings.libraries || []; } } catch (e) { /* non-fatal */ } return { success: true, libraries, serverName, machineId: identity.MediaContainer?.machineIdentifier }; } // Helper: Configure Prowlarr connected apps (Radarr/Sonarr) async function configureProwlarrApps(prowlarrUrl, prowlarrApiKey, apps) { const results = {}; // Check existing apps to avoid duplicates let existingApps = []; try { const existingRes = await ctx.fetchT(`${prowlarrUrl}/api/v1/applications`, { headers: { 'X-Api-Key': prowlarrApiKey }, signal: AbortSignal.timeout(10000) }); existingApps = existingRes.ok ? await existingRes.json() : []; } catch (e) { ctx.log.warn('arr', 'Could not fetch existing Prowlarr apps', { error: e.message }); } for (const [appName, config] of Object.entries(apps)) { const implementation = appName.charAt(0).toUpperCase() + appName.slice(1); // "Radarr", "Sonarr" // Skip if already configured if (existingApps.some(a => a.implementation === implementation)) { results[appName] = 'already_configured'; continue; } const syncCategories = appName === 'radarr' ? [2000, 2010, 2020, 2030, 2040, 2045, 2050, 2060] : [5000, 5010, 5020, 5030, 5040, 5045, 5050]; const payload = { name: implementation, syncLevel: 'fullSync', implementation: implementation, configContract: `${implementation}Settings`, fields: [ { name: 'prowlarrUrl', value: prowlarrUrl }, { name: 'baseUrl', value: config.url }, { name: 'apiKey', value: config.apiKey }, { name: 'syncCategories', value: syncCategories } ] }; try { const res = await ctx.fetchT(`${prowlarrUrl}/api/v1/applications`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Api-Key': prowlarrApiKey }, body: JSON.stringify(payload), signal: AbortSignal.timeout(10000) }); results[appName] = res.ok ? 'configured' : `failed: ${await res.text()}`; } catch (e) { results[appName] = `error: ${e.message}`; } } return results; } // Helper: Test a service connection (reusable logic) async function testServiceConnection(service, url, apiKey) { const baseUrl = url.replace(/\/+$/, ''); let apiEndpoint, headers; if (service === 'radarr' || service === 'sonarr' || service === 'lidarr') { apiEndpoint = `${baseUrl}/api/v3/system/status`; headers = { 'X-Api-Key': apiKey, 'Accept': 'application/json' }; } else if (service === 'prowlarr') { apiEndpoint = `${baseUrl}/api/v1/system/status`; headers = { 'X-Api-Key': apiKey, 'Accept': 'application/json' }; } else if (service === 'plex') { apiEndpoint = `${baseUrl}/identity`; headers = { 'X-Plex-Token': apiKey, 'Accept': 'application/json' }; } else { return { success: false, error: `Unknown service: ${service}` }; } try { const response = await ctx.fetchT(apiEndpoint, { method: 'GET', headers, signal: AbortSignal.timeout(15000) }); if (response.ok) { const data = await response.json(); if (service === 'plex') { return { success: true, version: data.MediaContainer?.version, appName: 'Plex' }; } return { success: true, version: data.version, appName: data.appName }; } else if (response.status === 401) { return { success: false, error: 'Invalid API key' }; } else { return { success: false, error: `HTTP ${response.status}` }; } } catch (e) { if (e.cause?.code === 'ECONNREFUSED') return { success: false, error: 'Connection refused' }; if (e.name === 'AbortError') return { success: false, error: 'Connection timeout' }; return { success: false, error: e.message }; } } // Helper: Get Overseerr API key (convenience wrapper) async function getOverseerrApiKey() { const session = await getOverseerrSession(); return session; } return { getArrApiKey, getPlexToken, getServiceUrl, getOverseerrSession, getOverseerrApiKey, connectPlexToOverseerr, configureProwlarrApps, testServiceConnection }; };