import express from "express"; import session from "express-session"; import RedisStore from "connect-redis"; import cors from "cors"; import helmet from "helmet"; import { config } from "./config"; import { redisClient } from "./utils/redis"; import { prisma } from "./utils/db"; import authRoutes from "./routes/auth"; import onboardingRoutes from "./routes/onboarding"; import libraryRoutes from "./routes/library"; import playsRoutes from "./routes/plays"; import settingsRoutes from "./routes/settings"; import systemSettingsRoutes from "./routes/systemSettings"; import listeningStateRoutes from "./routes/listeningState"; import playbackStateRoutes from "./routes/playbackState"; import offlineRoutes from "./routes/offline"; import playlistsRoutes from "./routes/playlists"; import searchRoutes from "./routes/search"; import recommendationsRoutes from "./routes/recommendations"; import downloadsRoutes from "./routes/downloads"; import webhooksRoutes from "./routes/webhooks"; import audiobooksRoutes from "./routes/audiobooks"; import podcastsRoutes from "./routes/podcasts"; import artistsRoutes from "./routes/artists"; import soulseekRoutes from "./routes/soulseek"; import discoverRoutes from "./routes/discover"; import apiKeysRoutes from "./routes/apiKeys"; import mixesRoutes from "./routes/mixes"; import enrichmentRoutes from "./routes/enrichment"; import homepageRoutes from "./routes/homepage"; import deviceLinkRoutes from "./routes/deviceLink"; import spotifyRoutes from "./routes/spotify"; import notificationsRoutes from "./routes/notifications"; import browseRoutes from "./routes/browse"; import analysisRoutes from "./routes/analysis"; import releasesRoutes from "./routes/releases"; import { dataCacheService } from "./services/dataCache"; import { errorHandler } from "./middleware/errorHandler"; import { authLimiter, apiLimiter, streamLimiter, imageLimiter, } from "./middleware/rateLimiter"; import swaggerUi from "swagger-ui-express"; import { swaggerSpec } from "./config/swagger"; const app = express(); // Middleware app.use( helmet({ crossOriginResourcePolicy: { policy: "cross-origin" }, }) ); app.use( cors({ origin: (origin, callback) => { // For self-hosted apps: allow all origins by default // Users deploy on their own domains/IPs - we can't predict them // Security is handled by authentication, not CORS if (!origin) { // Allow requests with no origin (same-origin, curl, etc.) callback(null, true); } else if ( config.allowedOrigins === true || config.nodeEnv === "development" ) { // Explicitly allow all origins callback(null, true); } else if ( Array.isArray(config.allowedOrigins) && config.allowedOrigins.length > 0 ) { // Check against specific allowed origins if configured if (config.allowedOrigins.includes(origin)) { callback(null, true); } else { // For self-hosted: allow anyway but log it // Users shouldn't have to configure CORS for their own app console.log( `[CORS] Origin ${origin} not in allowlist, allowing anyway (self-hosted)` ); callback(null, true); } } else { // No restrictions - allow all (self-hosted default) callback(null, true); } }, credentials: true, }) ); app.use(express.json({ limit: "1mb" })); // Increased from 100KB default to support large queue payloads // Session // Trust proxy for reverse proxy setups (nginx, traefik, etc.) app.set("trust proxy", 1); app.use( session({ store: new RedisStore({ client: redisClient, ttl: 7 * 24 * 60 * 60, // 7 days in seconds - must match cookie maxAge }), secret: config.sessionSecret, resave: false, saveUninitialized: false, proxy: true, // Trust the reverse proxy cookie: { httpOnly: true, // For self-hosted apps: allow HTTP access (common for LAN deployments) // If behind HTTPS reverse proxy, the proxy should handle security secure: false, sameSite: "lax", maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days }, }) ); // Routes - All API routes prefixed with /api for clear separation from frontend // Apply rate limiting to auth routes app.use("/api/auth/login", authLimiter); app.use("/api/auth/register", authLimiter); app.use("/api/auth", authRoutes); app.use("/api/onboarding", onboardingRoutes); // Public onboarding routes // Apply general API rate limiting to all API routes app.use("/api/api-keys", apiLimiter, apiKeysRoutes); app.use("/api/device-link", apiLimiter, deviceLinkRoutes); // NOTE: /api/library has its own rate limiting (imageLimiter for cover-art, apiLimiter for others) app.use("/api/library", libraryRoutes); app.use("/api/plays", apiLimiter, playsRoutes); app.use("/api/settings", apiLimiter, settingsRoutes); app.use("/api/system-settings", apiLimiter, systemSettingsRoutes); app.use("/api/listening-state", apiLimiter, listeningStateRoutes); app.use("/api/playback-state", playbackStateRoutes); // No rate limit - syncs frequently app.use("/api/offline", apiLimiter, offlineRoutes); app.use("/api/playlists", apiLimiter, playlistsRoutes); app.use("/api/search", apiLimiter, searchRoutes); app.use("/api/recommendations", apiLimiter, recommendationsRoutes); app.use("/api/downloads", apiLimiter, downloadsRoutes); app.use("/api/notifications", apiLimiter, notificationsRoutes); app.use("/api/webhooks", webhooksRoutes); // Webhooks should not be rate limited // NOTE: /api/audiobooks has its own rate limiting (imageLimiter for covers, apiLimiter for others) app.use("/api/audiobooks", audiobooksRoutes); app.use("/api/podcasts", apiLimiter, podcastsRoutes); app.use("/api/artists", apiLimiter, artistsRoutes); app.use("/api/soulseek", apiLimiter, soulseekRoutes); app.use("/api/discover", apiLimiter, discoverRoutes); app.use("/api/mixes", apiLimiter, mixesRoutes); app.use("/api/enrichment", apiLimiter, enrichmentRoutes); app.use("/api/homepage", apiLimiter, homepageRoutes); app.use("/api/spotify", apiLimiter, spotifyRoutes); app.use("/api/browse", apiLimiter, browseRoutes); app.use("/api/analysis", apiLimiter, analysisRoutes); app.use("/api/releases", apiLimiter, releasesRoutes); // Health check (keep at root for simple container health checks) app.get("/health", (req, res) => { res.json({ status: "ok" }); }); app.get("/api/health", (req, res) => { res.json({ status: "ok" }); }); // Swagger API Documentation app.use( "/api/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec, { customCss: ".swagger-ui .topbar { display: none }", customSiteTitle: "Lidify API Documentation", }) ); // Serve raw OpenAPI spec app.get("/api/docs.json", (req, res) => { res.json(swaggerSpec); }); // Error handler app.use(errorHandler); app.listen(config.port, "0.0.0.0", async () => { console.log( `Lidify API running on port ${config.port} (accessible on all network interfaces)` ); // Enable slow query monitoring in development if (config.nodeEnv === "development") { const { enableSlowQueryMonitoring } = await import( "./utils/queryMonitor" ); enableSlowQueryMonitoring(); } // Initialize music configuration (reads from SystemSettings) const { initializeMusicConfig } = await import("./config"); await initializeMusicConfig(); // Initialize Bull queue workers await import("./workers"); // Set up Bull Board dashboard const { createBullBoard } = await import("@bull-board/api"); const { BullAdapter } = await import("@bull-board/api/bullAdapter"); const { ExpressAdapter } = await import("@bull-board/express"); const { scanQueue, discoverQueue, imageQueue } = await import( "./workers/queues" ); const serverAdapter = new ExpressAdapter(); serverAdapter.setBasePath("/api/admin/queues"); createBullBoard({ queues: [ new BullAdapter(scanQueue), new BullAdapter(discoverQueue), new BullAdapter(imageQueue), ], serverAdapter, }); app.use("/api/admin/queues", serverAdapter.getRouter()); console.log("Bull Board dashboard available at /api/admin/queues"); // Note: Native library scanning is now triggered manually via POST /library/scan // No automatic sync on startup - user must manually scan their music folder // Enrichment worker enabled for OWNED content only // - Background enrichment: Genres, MBIDs, similar artists for owned albums/artists // - On-demand fetching: Artist images, bios when browsing (cached in Redis 7 days) console.log( "Background enrichment enabled for owned content (genres, MBIDs, etc.)" ); // Warm up Redis cache from database on startup // This populates Redis with existing artist images and album covers // so first page loads are instant instead of waiting for cache population dataCacheService.warmupCache().catch((err) => { console.error("Cache warmup failed:", err); }); // Podcast cache cleanup - runs daily to remove cached episodes older than 30 days const { cleanupExpiredCache } = await import("./services/podcastDownload"); // Run cleanup on startup (async, don't block) cleanupExpiredCache().catch((err) => { console.error("Podcast cache cleanup failed:", err); }); // Schedule daily cleanup (every 24 hours) const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; setInterval(() => { cleanupExpiredCache().catch((err) => { console.error("Scheduled podcast cache cleanup failed:", err); }); }, TWENTY_FOUR_HOURS); console.log("Podcast cache cleanup scheduled (daily, 30-day expiry)"); }); // Graceful shutdown handling let isShuttingDown = false; async function gracefulShutdown(signal: string) { if (isShuttingDown) { console.log("Shutdown already in progress..."); return; } isShuttingDown = true; console.log(`\nReceived ${signal}. Starting graceful shutdown...`); try { // Shutdown workers (intervals, crons, queues) const { shutdownWorkers } = await import("./workers"); await shutdownWorkers(); // Close Redis connection console.log("Closing Redis connection..."); await redisClient.quit(); // Close Prisma connection console.log("Closing database connection..."); await prisma.$disconnect(); console.log("Graceful shutdown complete"); process.exit(0); } catch (error) { console.error("Error during shutdown:", error); process.exit(1); } } // Handle termination signals process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); process.on("SIGINT", () => gracefulShutdown("SIGINT"));