diff --git a/.env.example b/.env.example index 1d45512..a0ec44d 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,10 @@ BETTER_AUTH_URL=http://localhost:3000 # OIDC_AUTO_REGISTER=true # Auto-create users on first OIDC login (default: true) # DISABLE_PASSWORD_LOGIN=false # Set to "true" to hide email/password form when OIDC is configured +# ─── Logging ────────────────────────────────────────────────────────── +# Log verbosity: error, warn, info, debug (default: info) +# LOG_LEVEL=info + # ─── Image Caching ───────────────────────────────────────────────────── # Set IMAGE_CACHE_ENABLED to "false" to use TMDB CDN directly (default: enabled) # IMAGE_CACHE_ENABLED=true diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 92ca572..e8c153e 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,13 +1,17 @@ import { sql } from "drizzle-orm"; import { NextResponse } from "next/server"; import { db } from "@/lib/db/client"; +import { createLogger } from "@/lib/logger"; + +const log = createLogger("health"); export async function GET() { try { db.run(sql`SELECT 1`); return NextResponse.json({ status: "healthy" }, { status: 200 }); - } catch { + } catch (err) { + log.error("Health check failed:", err); return NextResponse.json({ status: "unhealthy" }, { status: 503 }); } } diff --git a/app/api/webhooks/[token]/route.ts b/app/api/webhooks/[token]/route.ts index fc8b549..f00a021 100644 --- a/app/api/webhooks/[token]/route.ts +++ b/app/api/webhooks/[token]/route.ts @@ -3,6 +3,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { db } from "@/lib/db/client"; import { webhookConnections } from "@/lib/db/schema"; +import { createLogger } from "@/lib/logger"; import type { WebhookEvent } from "@/lib/services/webhooks"; import { parseJellyfinPayload, @@ -10,6 +11,8 @@ import { processWebhook, } from "@/lib/services/webhooks"; +const log = createLogger("webhooks"); + export async function POST( req: NextRequest, { params }: { params: Promise<{ token: string }> }, @@ -49,8 +52,9 @@ export async function POST( connection.provider, event, ); - } catch { + } catch (err) { // Swallow errors — never return non-200 to media servers + log.debug("Webhook processing failed:", err); } return NextResponse.json({ ok: true }); diff --git a/instrumentation.ts b/instrumentation.ts index 36d0bf2..0ff8364 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -4,6 +4,9 @@ export async function onRequestError() { export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { + const { createLogger } = await import("@/lib/logger"); + const log = createLogger("server"); + // Ensure image cache directories exist (all environments) const { ensureImageDirs, imageCacheEnabled } = await import( "@/lib/services/image-cache" @@ -24,11 +27,11 @@ export async function register() { const { closeDatabase } = await import("@/lib/db/client"); const shutdown = () => { - console.log("[shutdown] Stopping scheduler..."); + log.info("Stopping scheduler..."); stopJobs(); - console.log("[shutdown] Closing database..."); + log.info("Closing database..."); closeDatabase(); - console.log("[shutdown] Clean shutdown complete"); + log.info("Clean shutdown complete"); process.exit(0); }; diff --git a/lib/auth/server.ts b/lib/auth/server.ts index fcf78ca..e6bcc6e 100644 --- a/lib/auth/server.ts +++ b/lib/auth/server.ts @@ -9,6 +9,7 @@ import { isPasswordLoginDisabled, } from "@/lib/config"; import { db } from "@/lib/db/client"; +import { createLogger } from "@/lib/logger"; import { getUserCount, isRegistrationOpen, @@ -36,7 +37,18 @@ const oidcPlugin = isOidcConfigured() ] : []; +const authLog = createLogger("auth"); + export const auth = betterAuth({ + logger: { + // Suppress unset secret/low entropy warnings during build + disabled: process.env.NEXT_PHASE === "phase-production-build", + level: "debug", + log: (level, message, ...args) => { + const fn = authLog[level as keyof typeof authLog]; + if (fn) fn(message, ...args); + }, + }, database: drizzleAdapter(db, { provider: "sqlite", }), diff --git a/lib/cron.ts b/lib/cron.ts index 27b85e7..640188a 100644 --- a/lib/cron.ts +++ b/lib/cron.ts @@ -7,6 +7,7 @@ import { titles, userTitleStatus, } from "@/lib/db/schema"; +import { createLogger } from "@/lib/logger"; import { refreshAvailability } from "@/lib/services/availability"; import { cacheEpisodeStills, @@ -21,6 +22,8 @@ import { } from "@/lib/services/metadata"; import { getTvDetails } from "@/lib/tmdb/client"; +const log = createLogger("cron"); + const DAY = 24 * 60 * 60 * 1000; const RATE_LIMIT_MS = 300; @@ -42,13 +45,13 @@ function schedule(name: string, cron: string, handler: () => Promise) { name, protect: true, catch: (err: unknown) => { - console.error(`[scheduler] Job ${name} failed:`, err); + log.error(`Job ${name} failed:`, err); }, }, async () => { - console.log(`[scheduler] Running job: ${name}`); + log.info(`Running job: ${name}`); await handler(); - console.log(`[scheduler] Completed job: ${name}`); + log.info(`Completed job: ${name}`); }, ), ); @@ -213,7 +216,7 @@ export function startJobs() { schedule("refreshTvChildren", "30 */12 * * *", refreshTvChildrenJob); schedule("cacheImages", "0 1,13 * * *", cacheImagesJob); - console.log(`[scheduler] Started ${jobs.size} jobs`); + log.info(`Started ${jobs.size} jobs`); } export function stopJobs() { diff --git a/lib/db/migrate.ts b/lib/db/migrate.ts index 17dac0c..00c898f 100644 --- a/lib/db/migrate.ts +++ b/lib/db/migrate.ts @@ -1,8 +1,11 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import { createLogger } from "@/lib/logger"; import { db } from "./client"; +const log = createLogger("db"); + export function runMigrations() { - console.log("[migrate] Running database migrations..."); + log.info("Running database migrations..."); migrate(db, { migrationsFolder: "./drizzle" }); - console.log("[migrate] Database migrations complete"); + log.info("Database migrations complete"); } diff --git a/lib/logger.ts b/lib/logger.ts new file mode 100644 index 0000000..a3e8078 --- /dev/null +++ b/lib/logger.ts @@ -0,0 +1,27 @@ +const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 } as const; +type Level = keyof typeof LEVELS; + +const currentLevel: Level = + (process.env.LOG_LEVEL as Level) in LEVELS + ? (process.env.LOG_LEVEL as Level) + : "info"; + +export function createLogger(prefix: string) { + const fmt = (msg: string) => `[${prefix}] ${msg}`; + return { + error(msg: string, ...args: unknown[]) { + if (LEVELS[currentLevel] >= LEVELS.error) + console.error(fmt(msg), ...args); + }, + warn(msg: string, ...args: unknown[]) { + if (LEVELS[currentLevel] >= LEVELS.warn) console.warn(fmt(msg), ...args); + }, + info(msg: string, ...args: unknown[]) { + if (LEVELS[currentLevel] >= LEVELS.info) console.log(fmt(msg), ...args); + }, + debug(msg: string, ...args: unknown[]) { + if (LEVELS[currentLevel] >= LEVELS.debug) + console.debug(fmt(msg), ...args); + }, + }; +} diff --git a/lib/services/colors.ts b/lib/services/colors.ts index 187eb54..2dac1cd 100644 --- a/lib/services/colors.ts +++ b/lib/services/colors.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { Vibrant } from "node-vibrant/node"; import { db } from "@/lib/db/client"; import { titles } from "@/lib/db/schema"; +import { createLogger } from "@/lib/logger"; import { downloadAndCacheImage, getLocalImagePath, @@ -10,6 +11,8 @@ import { isImageCached, } from "@/lib/services/image-cache"; +const log = createLogger("colors"); + export interface ColorPalette { vibrant: string | null; darkVibrant: string | null; @@ -58,7 +61,7 @@ export async function extractAndStoreColors( return colors; } catch (err) { - console.error(`Failed to extract colors for title ${titleId}:`, err); + log.error(`Failed to extract colors for title ${titleId}:`, err); return null; } } diff --git a/lib/services/metadata.ts b/lib/services/metadata.ts index a0b6059..61de5a4 100644 --- a/lib/services/metadata.ts +++ b/lib/services/metadata.ts @@ -7,6 +7,7 @@ import { titleRecommendations, titles, } from "@/lib/db/schema"; +import { createLogger } from "@/lib/logger"; import { getMovieDetails, getRecommendations, @@ -29,6 +30,8 @@ import { imageCacheEnabled, } from "./image-cache"; +const log = createLogger("metadata"); + export async function importTitle( tmdbId: number, type: "movie" | "tv", @@ -277,10 +280,10 @@ export async function refreshTvChildren( }) .run(); } - } catch { + } catch (err) { // Skip this season and continue with the rest — partial data is // better than aborting entirely. The next refresh cycle will retry. - console.error(`Failed to fetch season ${sn} for TMDB ${tmdbId}`); + log.error(`Failed to fetch season ${sn} for TMDB ${tmdbId}:`, err); } } } @@ -457,8 +460,8 @@ export async function getTitleWithChildren(id: string): Promise<{ .run(); await refreshTvChildren(id, title.tmdbId, show.number_of_seasons); title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title; - } catch { - // Continue with whatever data we have + } catch (err) { + log.debug(`Failed to hydrate shell TV title ${id}:`, err); } } @@ -483,8 +486,8 @@ export async function getTitleWithChildren(id: string): Promise<{ .where(eq(titles.id, id)) .run(); title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title; - } catch { - // Continue with whatever data we have + } catch (err) { + log.debug(`Failed to hydrate shell movie title ${id}:`, err); } }