Files
lidify/backend/src/index.ts
T
2025-12-25 18:58:06 -06:00

301 lines
11 KiB
TypeScript

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"));