Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions
+712
View File
@@ -0,0 +1,712 @@
import { Router } from "express";
import { requireAuth, requireAdmin } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
import { writeEnvFile } from "../utils/envWriter";
import { invalidateSystemSettingsCache } from "../utils/systemSettings";
import { queueCleaner } from "../jobs/queueCleaner";
import { encrypt, decrypt } from "../utils/encryption";
const router = Router();
/**
* Safely decrypt a field, returning null if decryption fails
*/
function safeDecrypt(value: string | null): string | null {
if (!value) return null;
try {
return decrypt(value);
} catch (error) {
console.warn("[Settings Route] Failed to decrypt field, returning null");
return null;
}
}
// Only admins can access system settings
router.use(requireAuth);
router.use(requireAdmin);
const systemSettingsSchema = z.object({
// Download Services
lidarrEnabled: z.boolean().optional(),
lidarrUrl: z.string().optional(),
lidarrApiKey: z.string().nullable().optional(),
// AI Services
openaiEnabled: z.boolean().optional(),
openaiApiKey: z.string().nullable().optional(),
openaiModel: z.string().optional(),
openaiBaseUrl: z.string().nullable().optional(),
fanartEnabled: z.boolean().optional(),
fanartApiKey: z.string().nullable().optional(),
// Media Services
audiobookshelfEnabled: z.boolean().optional(),
audiobookshelfUrl: z.string().optional(),
audiobookshelfApiKey: z.string().nullable().optional(),
// Soulseek (direct connection via slsk-client)
soulseekUsername: z.string().nullable().optional(),
soulseekPassword: z.string().nullable().optional(),
// Spotify (for playlist import)
spotifyClientId: z.string().nullable().optional(),
spotifyClientSecret: z.string().nullable().optional(),
// Storage Paths
musicPath: z.string().optional(),
downloadPath: z.string().optional(),
// Feature Flags
autoSync: z.boolean().optional(),
autoEnrichMetadata: z.boolean().optional(),
// Advanced Settings
maxConcurrentDownloads: z.number().optional(),
downloadRetryAttempts: z.number().optional(),
transcodeCacheMaxGb: z.number().optional(),
// Download Preferences
downloadSource: z.enum(["soulseek", "lidarr"]).optional(),
soulseekFallback: z.enum(["none", "lidarr"]).optional(),
});
// GET /system-settings
router.get("/", async (req, res) => {
try {
let settings = await prisma.systemSettings.findUnique({
where: { id: "default" },
});
// Create default settings if they don't exist
if (!settings) {
settings = await prisma.systemSettings.create({
data: {
id: "default",
lidarrEnabled: true,
lidarrUrl: "http://localhost:8686",
openaiEnabled: false,
openaiModel: "gpt-4",
fanartEnabled: false,
audiobookshelfEnabled: false,
audiobookshelfUrl: "http://localhost:13378",
musicPath: "/music",
downloadPath: "/downloads",
autoSync: true,
autoEnrichMetadata: true,
maxConcurrentDownloads: 3,
downloadRetryAttempts: 3,
transcodeCacheMaxGb: 10,
},
});
}
// Decrypt sensitive fields before sending to client
// Use safeDecrypt to handle corrupted encrypted values gracefully
const decryptedSettings = {
...settings,
lidarrApiKey: safeDecrypt(settings.lidarrApiKey),
openaiApiKey: safeDecrypt(settings.openaiApiKey),
fanartApiKey: safeDecrypt(settings.fanartApiKey),
audiobookshelfApiKey: safeDecrypt(settings.audiobookshelfApiKey),
soulseekPassword: safeDecrypt(settings.soulseekPassword),
spotifyClientSecret: safeDecrypt(settings.spotifyClientSecret),
};
res.json(decryptedSettings);
} catch (error) {
console.error("Get system settings error:", error);
res.status(500).json({ error: "Failed to get system settings" });
}
});
// POST /system-settings
router.post("/", async (req, res) => {
try {
const data = systemSettingsSchema.parse(req.body);
console.log("[SYSTEM SETTINGS] Saving settings...");
console.log(
"[SYSTEM SETTINGS] transcodeCacheMaxGb:",
data.transcodeCacheMaxGb
);
// Encrypt sensitive fields
const encryptedData: any = { ...data };
if (data.lidarrApiKey)
encryptedData.lidarrApiKey = encrypt(data.lidarrApiKey);
if (data.openaiApiKey)
encryptedData.openaiApiKey = encrypt(data.openaiApiKey);
if (data.fanartApiKey)
encryptedData.fanartApiKey = encrypt(data.fanartApiKey);
if (data.audiobookshelfApiKey)
encryptedData.audiobookshelfApiKey = encrypt(
data.audiobookshelfApiKey
);
if (data.soulseekPassword)
encryptedData.soulseekPassword = encrypt(data.soulseekPassword);
if (data.spotifyClientSecret)
encryptedData.spotifyClientSecret = encrypt(data.spotifyClientSecret);
const settings = await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
...encryptedData,
},
update: encryptedData,
});
invalidateSystemSettingsCache();
// If Audiobookshelf was disabled, clear all audiobook-related data
if (data.audiobookshelfEnabled === false) {
console.log(
"[CLEANUP] Audiobookshelf disabled - clearing all audiobook data from database"
);
try {
const deletedProgress =
await prisma.audiobookProgress.deleteMany({});
console.log(
` Deleted ${deletedProgress.count} audiobook progress entries`
);
} catch (clearError) {
console.error("Failed to clear audiobook data:", clearError);
// Don't fail the request
}
}
// Write to .env file for Docker containers
try {
await writeEnvFile({
LIDARR_ENABLED: data.lidarrEnabled ? "true" : "false",
LIDARR_URL: data.lidarrUrl || null,
LIDARR_API_KEY: data.lidarrApiKey || null,
FANART_API_KEY: data.fanartApiKey || null,
OPENAI_API_KEY: data.openaiApiKey || null,
AUDIOBOOKSHELF_URL: data.audiobookshelfUrl || null,
AUDIOBOOKSHELF_API_KEY: data.audiobookshelfApiKey || null,
SOULSEEK_USERNAME: data.soulseekUsername || null,
SOULSEEK_PASSWORD: data.soulseekPassword || null,
});
console.log(".env file synchronized with database settings");
} catch (envError) {
console.error("Failed to write .env file:", envError);
// Don't fail the request if .env write fails
}
// Auto-configure Lidarr webhook if Lidarr is enabled
if (data.lidarrEnabled && data.lidarrUrl && data.lidarrApiKey) {
try {
console.log("[LIDARR] Auto-configuring webhook...");
const axios = (await import("axios")).default;
const lidarrUrl = data.lidarrUrl;
const apiKey = data.lidarrApiKey;
// Determine webhook URL
// Use LIDIFY_CALLBACK_URL env var if set, otherwise default to host.docker.internal:3030
// Port 3030 is the external Nginx port that Lidarr can reach
const callbackHost = process.env.LIDIFY_CALLBACK_URL || "http://host.docker.internal:3030";
const webhookUrl = `${callbackHost}/api/webhooks/lidarr`;
console.log(` Webhook URL: ${webhookUrl}`);
// Check if webhook already exists - find by name "Lidify" OR by URL containing "lidify" or "webhooks/lidarr"
const notificationsResponse = await axios.get(
`${lidarrUrl}/api/v1/notification`,
{
headers: { "X-Api-Key": apiKey },
timeout: 10000,
}
);
// Find existing Lidify webhook by name (primary) or URL pattern (fallback)
const existingWebhook = notificationsResponse.data.find(
(n: any) =>
n.implementation === "Webhook" &&
(
// Match by name
n.name === "Lidify" ||
// Or match by URL pattern (catches old webhooks with different URLs)
n.fields?.find(
(f: any) =>
f.name === "url" &&
(f.value?.includes("webhooks/lidarr") || f.value?.includes("lidify"))
)
)
);
if (existingWebhook) {
const currentUrl = existingWebhook.fields?.find((f: any) => f.name === "url")?.value;
console.log(` Found existing webhook: "${existingWebhook.name}" with URL: ${currentUrl}`);
if (currentUrl !== webhookUrl) {
console.log(` URL needs updating from: ${currentUrl}`);
console.log(` URL will be updated to: ${webhookUrl}`);
}
}
const webhookConfig = {
onGrab: true,
onReleaseImport: true,
onAlbumDownload: true,
onDownloadFailure: true,
onImportFailure: true,
onAlbumDelete: true,
onRename: true,
onHealthIssue: false,
onApplicationUpdate: false,
supportsOnGrab: true,
supportsOnReleaseImport: true,
supportsOnAlbumDownload: true,
supportsOnDownloadFailure: true,
supportsOnImportFailure: true,
supportsOnAlbumDelete: true,
supportsOnRename: true,
supportsOnHealthIssue: true,
supportsOnApplicationUpdate: true,
includeHealthWarnings: false,
name: "Lidify",
implementation: "Webhook",
implementationName: "Webhook",
configContract: "WebhookSettings",
infoLink:
"https://wiki.servarr.com/lidarr/supported#webhook",
tags: [],
fields: [
{ name: "url", value: webhookUrl },
{ name: "method", value: 1 }, // 1 = POST
{ name: "username", value: "" },
{ name: "password", value: "" },
],
};
if (existingWebhook) {
// Update existing webhook
await axios.put(
`${lidarrUrl}/api/v1/notification/${existingWebhook.id}?forceSave=true`,
{ ...existingWebhook, ...webhookConfig },
{
headers: { "X-Api-Key": apiKey },
timeout: 10000,
}
);
console.log(" Webhook updated");
} else {
// Create new webhook (use forceSave to skip test)
await axios.post(
`${lidarrUrl}/api/v1/notification?forceSave=true`,
webhookConfig,
{
headers: { "X-Api-Key": apiKey },
timeout: 10000,
}
);
console.log(" Webhook created");
}
console.log("Lidarr webhook configured automatically\n");
} catch (webhookError: any) {
console.error(
"Failed to auto-configure webhook:",
webhookError.message
);
if (webhookError.response?.data) {
console.error(
" Lidarr error details:",
JSON.stringify(webhookError.response.data, null, 2)
);
}
console.log(
" User can configure webhook manually in Lidarr UI\n"
);
// Don't fail the request if webhook config fails
}
}
res.json({
success: true,
message:
"Settings saved successfully. Restart Docker containers to apply changes.",
requiresRestart: true,
});
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid settings", details: error.errors });
}
console.error("Update system settings error:", error);
res.status(500).json({ error: "Failed to update system settings" });
}
});
// POST /system-settings/test-lidarr
router.post("/test-lidarr", async (req, res) => {
try {
const { url, apiKey } = req.body;
console.log("[Lidarr Test] Testing connection to:", url);
if (!url || !apiKey) {
return res
.status(400)
.json({ error: "URL and API key are required" });
}
// Normalize URL - remove trailing slash
const normalizedUrl = url.replace(/\/+$/, "");
const axios = require("axios");
const response = await axios.get(
`${normalizedUrl}/api/v1/system/status`,
{
headers: { "X-Api-Key": apiKey },
timeout: 10000,
}
);
console.log(
"[Lidarr Test] Connection successful, version:",
response.data.version
);
res.json({
success: true,
message: "Lidarr connection successful",
version: response.data.version,
});
} catch (error: any) {
console.error("[Lidarr Test] Error:", error.message);
console.error(
"[Lidarr Test] Details:",
error.response?.data || error.code
);
let details = error.message;
if (error.code === "ECONNREFUSED") {
details =
"Connection refused - check if Lidarr is running and accessible";
} else if (error.code === "ENOTFOUND") {
details = "Host not found - check the URL";
} else if (error.response?.status === 401) {
details = "Invalid API key";
} else if (error.response?.data?.message) {
details = error.response.data.message;
}
res.status(500).json({
error: "Failed to connect to Lidarr",
details,
});
}
});
// POST /system-settings/test-openai
router.post("/test-openai", async (req, res) => {
try {
const { apiKey, model } = req.body;
if (!apiKey) {
return res.status(400).json({ error: "API key is required" });
}
const axios = require("axios");
const response = await axios.post(
"https://api.openai.com/v1/chat/completions",
{
model: model || "gpt-3.5-turbo",
messages: [{ role: "user", content: "Test" }],
max_tokens: 5,
},
{
headers: { Authorization: `Bearer ${apiKey}` },
timeout: 10000,
}
);
res.json({
success: true,
message: "OpenAI connection successful",
model: response.data.model,
});
} catch (error: any) {
console.error("OpenAI test error:", error.message);
res.status(500).json({
error: "Failed to connect to OpenAI",
details: error.response?.data?.error?.message || error.message,
});
}
});
// Test Fanart.tv connection
router.post("/test-fanart", async (req, res) => {
try {
const { fanartApiKey } = req.body;
if (!fanartApiKey) {
return res.status(400).json({ error: "API key is required" });
}
const axios = require("axios");
// Test with a known artist (The Beatles MBID)
const testMbid = "b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d";
const response = await axios.get(
`https://webservice.fanart.tv/v3/music/${testMbid}`,
{
params: { api_key: fanartApiKey },
timeout: 5000,
}
);
// If we get here, the API key is valid
res.json({
success: true,
message: "Fanart.tv connection successful",
});
} catch (error: any) {
console.error("Fanart.tv test error:", error.message);
if (error.response?.status === 401) {
res.status(401).json({
error: "Invalid Fanart.tv API key",
});
} else {
res.status(500).json({
error: "Failed to connect to Fanart.tv",
details: error.response?.data || error.message,
});
}
}
});
// Test Audiobookshelf connection
router.post("/test-audiobookshelf", async (req, res) => {
try {
const { url, apiKey } = req.body;
if (!url || !apiKey) {
return res
.status(400)
.json({ error: "URL and API key are required" });
}
const axios = require("axios");
const response = await axios.get(`${url}/api/libraries`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
timeout: 5000,
});
res.json({
success: true,
message: "Audiobookshelf connection successful",
libraries: response.data.libraries?.length || 0,
});
} catch (error: any) {
console.error("Audiobookshelf test error:", error.message);
if (error.response?.status === 401 || error.response?.status === 403) {
res.status(401).json({
error: "Invalid Audiobookshelf API key",
});
} else {
res.status(500).json({
error: "Failed to connect to Audiobookshelf",
details: error.response?.data || error.message,
});
}
}
});
// Test Soulseek connection (direct via slsk-client)
router.post("/test-soulseek", async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({
error: "Soulseek username and password are required",
});
}
console.log(`[SOULSEEK-TEST] Testing connection as "${username}"...`);
// Import soulseek service
const { soulseekService } = await import("../services/soulseek");
// Temporarily set credentials for test
// The service will use the provided credentials
try {
// Try to connect with the provided credentials
const slsk = require("slsk-client");
await new Promise<void>((resolve, reject) => {
slsk.connect(
{ user: username, pass: password },
(err: Error | null, client: any) => {
if (err) {
console.log(`[SOULSEEK-TEST] Connection failed: ${err.message}`);
return reject(err);
}
console.log(`[SOULSEEK-TEST] Connected successfully`);
// We don't need to keep the connection open for the test
resolve();
}
);
});
res.json({
success: true,
message: `Connected to Soulseek as "${username}"`,
soulseekUsername: username,
isConnected: true,
});
} catch (connectError: any) {
console.error(`[SOULSEEK-TEST] Error: ${connectError.message}`);
res.status(401).json({
error: "Invalid Soulseek credentials or connection failed",
details: connectError.message,
});
}
} catch (error: any) {
console.error("[SOULSEEK-TEST] Error:", error.message);
res.status(500).json({
error: "Failed to test Soulseek connection",
details: error.message,
});
}
});
// Test Spotify credentials
router.post("/test-spotify", async (req, res) => {
try {
const { clientId, clientSecret } = req.body;
if (!clientId || !clientSecret) {
return res.status(400).json({
error: "Client ID and Client Secret are required"
});
}
// Import spotifyService to test credentials
const { spotifyService } = await import("../services/spotify");
const result = await spotifyService.testCredentials(clientId, clientSecret);
if (result.success) {
res.json({
success: true,
message: "Spotify credentials are valid",
});
} else {
res.status(401).json({
error: result.error || "Invalid Spotify credentials",
});
}
} catch (error: any) {
console.error("Spotify test error:", error.message);
res.status(500).json({
error: "Failed to test Spotify credentials",
details: error.message,
});
}
});
// Get queue cleaner status
router.get("/queue-cleaner-status", (req, res) => {
res.json(queueCleaner.getStatus());
});
// Start queue cleaner manually
router.post("/queue-cleaner/start", async (req, res) => {
try {
await queueCleaner.start();
res.json({
success: true,
message: "Queue cleaner started",
status: queueCleaner.getStatus(),
});
} catch (error: any) {
res.status(500).json({
error: "Failed to start queue cleaner",
details: error.message,
});
}
});
// Stop queue cleaner manually
router.post("/queue-cleaner/stop", (req, res) => {
queueCleaner.stop();
res.json({
success: true,
message: "Queue cleaner stopped",
status: queueCleaner.getStatus(),
});
});
// Clear all Redis caches
router.post("/clear-caches", async (req, res) => {
try {
const { redisClient } = require("../utils/redis");
const { notificationService } = await import("../services/notificationService");
// Get all keys but exclude session keys
const allKeys = await redisClient.keys("*");
const keysToDelete = allKeys.filter(
(key: string) => !key.startsWith("sess:")
);
if (keysToDelete.length > 0) {
console.log(
`[CACHE] Clearing ${
keysToDelete.length
} cache entries (excluding ${
allKeys.length - keysToDelete.length
} session keys)...`
);
for (const key of keysToDelete) {
await redisClient.del(key);
}
console.log(
`[CACHE] Successfully cleared ${keysToDelete.length} cache entries`
);
// Send notification to user
await notificationService.notifySystem(
req.user!.id,
"Caches Cleared",
`Successfully cleared ${keysToDelete.length} cache entries`
);
res.json({
success: true,
message: `Cleared ${keysToDelete.length} cache entries`,
clearedKeys: keysToDelete.length,
});
} else {
await notificationService.notifySystem(
req.user!.id,
"Caches Cleared",
"No cache entries to clear"
);
res.json({
success: true,
message: "No cache entries to clear",
clearedKeys: 0,
});
}
} catch (error: any) {
console.error("Clear caches error:", error);
res.status(500).json({
error: "Failed to clear caches",
details: error.message,
});
}
});
export default router;