303 lines
10 KiB
JavaScript
303 lines
10 KiB
JavaScript
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>([^<]+)<\/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
|
|
};
|
|
};
|