Add verbose debug logging across services and cron jobs

Add createLogger to tmdb client, image-cache, availability, and
webhooks services. Replace all silent .catch(() => {}) in metadata.ts
with debug-level error logging. Add progress and success logs in cron
job bodies, colors extraction, and webhook processing. Fix pre-existing
unused parameter lint warning in title-card.tsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 4.6 committed 2026-03-05 12:46:06 -05:00
1 parent 2d691c3591
commit 704aca1103
8 files changed
+156 -35

No files matched your search

+1 -1
View File
@@ -255,7 +255,7 @@ function CardInner({
/** Linked title card for library grids, recommendations, dashboards */ /** Linked title card for library grids, recommendations, dashboards */
export function TitleCard({ export function TitleCard({
id, id,
tmdbId, tmdbId: _tmdbId,
type, type,
title, title,
posterPath, posterPath,
+10 -2
View File
@@ -115,6 +115,7 @@ function getLibraryTitleIds(): string[] {
// Refresh titles where lastFetchedAt is stale // Refresh titles where lastFetchedAt is stale
async function nightlyRefreshLibrary() { async function nightlyRefreshLibrary() {
const libraryIds = getLibraryTitleIds(); const libraryIds = getLibraryTitleIds();
log.debug(`Checking ${libraryIds.length} library titles for staleness`);
const libraryStale = new Date(Date.now() - 7 * DAY); const libraryStale = new Date(Date.now() - 7 * DAY);
const nonLibraryStale = new Date(Date.now() - 30 * DAY); const nonLibraryStale = new Date(Date.now() - 30 * DAY);
@@ -157,6 +158,7 @@ async function nightlyRefreshLibrary() {
// Refresh availability for library titles where stale // Refresh availability for library titles where stale
async function refreshAvailabilityJob() { async function refreshAvailabilityJob() {
const libraryIds = getLibraryTitleIds(); const libraryIds = getLibraryTitleIds();
log.debug(`Checking availability for ${libraryIds.length} library titles`);
const stale = new Date(Date.now() - DAY); const stale = new Date(Date.now() - DAY);
for (const titleId of libraryIds) { for (const titleId of libraryIds) {
@@ -189,6 +191,9 @@ async function refreshAvailabilityJob() {
// Refresh recommendations for recently active titles // Refresh recommendations for recently active titles
async function refreshRecommendationsJob() { async function refreshRecommendationsJob() {
const libraryIds = getLibraryTitleIds(); const libraryIds = getLibraryTitleIds();
log.debug(
`Refreshing recommendations for ${libraryIds.length} library titles`,
);
for (const titleId of libraryIds) { for (const titleId of libraryIds) {
await refreshRecommendations(titleId); await refreshRecommendations(titleId);
@@ -213,6 +218,8 @@ async function refreshTvChildrenJob() {
) )
.all(); .all();
log.debug(`Checking ${tvShows.length} returning TV shows for stale episodes`);
for (const show of tvShows) { for (const show of tvShows) {
// Check if seasons are stale // Check if seasons are stale
const staleSeason = db const staleSeason = db
@@ -236,6 +243,7 @@ async function cacheImagesJob() {
if (!imageCacheEnabled()) return; if (!imageCacheEnabled()) return;
const libraryIds = getLibraryTitleIds(); const libraryIds = getLibraryTitleIds();
log.debug(`Caching images for ${libraryIds.length} library titles`);
for (const titleId of libraryIds) { for (const titleId of libraryIds) {
try { try {
@@ -244,8 +252,8 @@ async function cacheImagesJob() {
cacheEpisodeStills(titleId), cacheEpisodeStills(titleId),
cacheProviderLogos(titleId), cacheProviderLogos(titleId),
]); ]);
} catch { } catch (err) {
// Continue with remaining titles log.warn(`Failed to cache images for title ${titleId}:`, err);
} }
await Bun.sleep(RATE_LIMIT_MS); await Bun.sleep(RATE_LIMIT_MS);
} }
+10 -1
View File
@@ -1,15 +1,21 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { availabilityOffers, titles } from "@/lib/db/schema"; import { availabilityOffers, titles } from "@/lib/db/schema";
import { createLogger } from "@/lib/logger";
import { getWatchProviders } from "@/lib/tmdb/client"; import { getWatchProviders } from "@/lib/tmdb/client";
const log = createLogger("availability");
export async function refreshAvailability(titleId: string) { export async function refreshAvailability(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get(); const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return; if (!title) return;
const data = await getWatchProviders(title.tmdbId, title.type); const data = await getWatchProviders(title.tmdbId, title.type);
const us = data.results?.US; const us = data.results?.US;
if (!us) return; if (!us) {
log.debug(`No US providers for title ${titleId}`);
return;
}
const now = new Date(); const now = new Date();
const offerTypes = ["flatrate", "rent", "buy", "free", "ads"] as const; const offerTypes = ["flatrate", "rent", "buy", "free", "ads"] as const;
@@ -44,6 +50,9 @@ export async function refreshAvailability(titleId: string) {
.run(); .run();
} }
} }
const total = offerTypes.reduce((n, t) => n + (us[t]?.length ?? 0), 0);
log.debug(`Refreshed availability for title ${titleId}: ${total} offers`);
} }
export function getAvailability(titleId: string) { export function getAvailability(titleId: string) {
+6
View File
@@ -43,6 +43,9 @@ export async function extractAndStoreColors(
} }
try { try {
log.debug(
`Extracting colors for title ${titleId} from ${imageCacheEnabled() ? "cache" : "remote"}`,
);
const palette = await Vibrant.from(source).getPalette(); const palette = await Vibrant.from(source).getPalette();
const colors: ColorPalette = { const colors: ColorPalette = {
@@ -59,6 +62,9 @@ export async function extractAndStoreColors(
.where(eq(titles.id, titleId)) .where(eq(titles.id, titleId))
.run(); .run();
log.debug(
`Extracted colors for title ${titleId}: colors=${JSON.stringify(colors)}`,
);
return colors; return colors;
} catch (err) { } catch (err) {
log.error(`Failed to extract colors for title ${titleId}:`, err); log.error(`Failed to extract colors for title ${titleId}:`, err);
+20 -5
View File
@@ -3,6 +3,9 @@ import path from "node:path";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { availabilityOffers, episodes, seasons, titles } from "@/lib/db/schema"; import { availabilityOffers, episodes, seasons, titles } from "@/lib/db/schema";
import { createLogger } from "@/lib/logger";
const log = createLogger("image-cache");
export type ImageCategory = "posters" | "backdrops" | "stills" | "logos"; export type ImageCategory = "posters" | "backdrops" | "stills" | "logos";
@@ -63,7 +66,10 @@ export async function downloadAndCacheImage(
const url = `${IMAGE_BASE_URL}/${size}${tmdbPath}`; const url = `${IMAGE_BASE_URL}/${size}${tmdbPath}`;
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) return null; if (!res.ok) {
log.warn(`Download failed: ${url} -> ${res.status}`);
return null;
}
const buffer = Buffer.from(await res.arrayBuffer()); const buffer = Buffer.from(await res.arrayBuffer());
const filename = path.basename(tmdbPath); const filename = path.basename(tmdbPath);
@@ -73,8 +79,9 @@ export async function downloadAndCacheImage(
try { try {
await Bun.write(tmpPath, buffer); await Bun.write(tmpPath, buffer);
await rename(tmpPath, finalPath); await rename(tmpPath, finalPath);
} catch { log.debug(`Cached ${category}/${filename} (${buffer.length} bytes)`);
// Best-effort cleanup } catch (err) {
log.warn(`Failed to write cached image ${filename}:`, err);
} }
return buffer; return buffer;
@@ -88,6 +95,7 @@ export async function fetchAndMaybeCache(
const filename = path.basename(tmdbPath); const filename = path.basename(tmdbPath);
const cached = await readCachedImage(category, filename); const cached = await readCachedImage(category, filename);
if (cached) { if (cached) {
log.debug(`Cache hit: ${category}/${filename}`);
const ext = path.extname(filename).toLowerCase(); const ext = path.extname(filename).toLowerCase();
const contentType = const contentType =
ext === ".png" ext === ".png"
@@ -102,7 +110,10 @@ export async function fetchAndMaybeCache(
const size = CATEGORY_SIZES[category]; const size = CATEGORY_SIZES[category];
const url = `${IMAGE_BASE_URL}/${size}${tmdbPath}`; const url = `${IMAGE_BASE_URL}/${size}${tmdbPath}`;
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) return null; if (!res.ok) {
log.warn(`Fetch failed: ${url} -> ${res.status}`);
return null;
}
const buffer = Buffer.from(await res.arrayBuffer()); const buffer = Buffer.from(await res.arrayBuffer());
const contentType = res.headers.get("content-type") || "image/jpeg"; const contentType = res.headers.get("content-type") || "image/jpeg";
@@ -112,7 +123,7 @@ export async function fetchAndMaybeCache(
const tmpPath = `${finalPath}.tmp.${Date.now()}`; const tmpPath = `${finalPath}.tmp.${Date.now()}`;
Bun.write(tmpPath, buffer) Bun.write(tmpPath, buffer)
.then(() => rename(tmpPath, finalPath)) .then(() => rename(tmpPath, finalPath))
.catch(() => {}); .catch((err) => log.warn(`Failed to cache ${category}/${filename}:`, err));
return { buffer, contentType }; return { buffer, contentType };
} }
@@ -153,6 +164,10 @@ export async function cacheImagesForTitle(titleId: string) {
} }
} }
if (tasks.length > 0) {
log.debug(`Caching ${tasks.length} images for title ${titleId}`);
}
await Promise.allSettled(tasks); await Promise.allSettled(tasks);
} }
+86 -26
View File
@@ -37,6 +37,8 @@ export async function importTitle(
type: "movie" | "tv", type: "movie" | "tv",
options?: { awaitEnrichment?: boolean }, options?: { awaitEnrichment?: boolean },
) { ) {
log.debug(`Importing ${type} TMDB ${tmdbId}`);
const awaitEnrichment = options?.awaitEnrichment ?? false; const awaitEnrichment = options?.awaitEnrichment ?? false;
const existing = db const existing = db
.select() .select()
@@ -70,19 +72,31 @@ export async function importTitle(
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons); await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
if (awaitEnrichment) { if (awaitEnrichment) {
await Promise.all([ await Promise.all([
refreshAvailability(existing.id).catch(() => {}), refreshAvailability(existing.id).catch((err) =>
refreshRecommendations(existing.id).catch(() => {}), log.debug("Availability enrichment failed:", err),
extractAndStoreColors(existing.id, show.poster_path).catch( ),
() => {}, refreshRecommendations(existing.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
),
extractAndStoreColors(existing.id, show.poster_path).catch((err) =>
log.debug("Color extraction failed:", err),
), ),
]); ]);
} else { } else {
refreshAvailability(existing.id).catch(() => {}); refreshAvailability(existing.id).catch((err) =>
refreshRecommendations(existing.id).catch(() => {}); log.debug("Availability enrichment failed:", err),
);
refreshRecommendations(existing.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
} }
if (imageCacheEnabled()) { if (imageCacheEnabled()) {
cacheImagesForTitle(existing.id).catch(() => {}); cacheImagesForTitle(existing.id).catch((err) =>
cacheEpisodeStills(existing.id).catch(() => {}); log.debug("Image caching failed:", err),
);
cacheEpisodeStills(existing.id).catch((err) =>
log.debug("Episode stills caching failed:", err),
);
} }
return db.select().from(titles).where(eq(titles.id, existing.id)).get(); return db.select().from(titles).where(eq(titles.id, existing.id)).get();
} }
@@ -115,16 +129,33 @@ export async function importTitle(
.get(); .get();
if (awaitEnrichment) { if (awaitEnrichment) {
await Promise.all([ await Promise.all([
refreshAvailability(row.id).catch(() => {}), refreshAvailability(row.id).catch((err) =>
refreshRecommendations(row.id).catch(() => {}), log.debug("Availability enrichment failed:", err),
extractAndStoreColors(row.id, movie.poster_path).catch(() => {}), ),
refreshRecommendations(row.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
),
extractAndStoreColors(row.id, movie.poster_path).catch((err) =>
log.debug("Color extraction failed:", err),
),
]); ]);
} else { } else {
refreshAvailability(row.id).catch(() => {}); refreshAvailability(row.id).catch((err) =>
refreshRecommendations(row.id).catch(() => {}); log.debug("Availability enrichment failed:", err),
extractAndStoreColors(row.id, movie.poster_path).catch(() => {}); );
refreshRecommendations(row.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
extractAndStoreColors(row.id, movie.poster_path).catch((err) =>
log.debug("Color extraction failed:", err),
);
} }
if (imageCacheEnabled()) cacheImagesForTitle(row.id).catch(() => {}); if (imageCacheEnabled()) {
cacheImagesForTitle(row.id).catch((err) =>
log.debug("Image caching failed:", err),
);
}
log.info(`Imported movie "${movie.title}" (TMDB ${tmdbId})`);
return row; return row;
} }
@@ -152,19 +183,36 @@ export async function importTitle(
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons); await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
if (awaitEnrichment) { if (awaitEnrichment) {
await Promise.all([ await Promise.all([
refreshAvailability(row.id).catch(() => {}), refreshAvailability(row.id).catch((err) =>
refreshRecommendations(row.id).catch(() => {}), log.debug("Availability enrichment failed:", err),
extractAndStoreColors(row.id, show.poster_path).catch(() => {}), ),
refreshRecommendations(row.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
),
extractAndStoreColors(row.id, show.poster_path).catch((err) =>
log.debug("Color extraction failed:", err),
),
]); ]);
} else { } else {
refreshAvailability(row.id).catch(() => {}); refreshAvailability(row.id).catch((err) =>
refreshRecommendations(row.id).catch(() => {}); log.debug("Availability enrichment failed:", err),
extractAndStoreColors(row.id, show.poster_path).catch(() => {}); );
refreshRecommendations(row.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
extractAndStoreColors(row.id, show.poster_path).catch((err) =>
log.debug("Color extraction failed:", err),
);
} }
if (imageCacheEnabled()) { if (imageCacheEnabled()) {
cacheImagesForTitle(row.id).catch(() => {}); cacheImagesForTitle(row.id).catch((err) =>
cacheEpisodeStills(row.id).catch(() => {}); log.debug("Image caching failed:", err),
);
cacheEpisodeStills(row.id).catch((err) =>
log.debug("Episode stills caching failed:", err),
);
} }
log.info(`Imported TV show "${show.name}" (TMDB ${tmdbId})`);
return row; return row;
} }
@@ -215,10 +263,18 @@ export async function refreshTitle(titleId: string) {
const updated = db.select().from(titles).where(eq(titles.id, titleId)).get(); const updated = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (updated) { if (updated) {
extractAndStoreColors(updated.id, updated.posterPath).catch(() => {}); extractAndStoreColors(updated.id, updated.posterPath).catch((err) =>
log.debug("Color extraction failed:", err),
);
if (imageCacheEnabled()) { if (imageCacheEnabled()) {
cacheImagesForTitle(updated.id).catch(() => {}); cacheImagesForTitle(updated.id).catch((err) =>
if (updated.type === "tv") cacheEpisodeStills(updated.id).catch(() => {}); log.debug("Image caching failed:", err),
);
if (updated.type === "tv") {
cacheEpisodeStills(updated.id).catch((err) =>
log.debug("Episode stills caching failed:", err),
);
}
} }
} }
return updated; return updated;
@@ -305,6 +361,10 @@ export async function refreshRecommendations(titleId: string) {
getSimilar(title.tmdbId, title.type), getSimilar(title.tmdbId, title.type),
]); ]);
log.debug(
`Fetched ${recs.results.length} recommendations and ${similar.results.length} similar for title ${titleId}`,
);
// Process recommendations // Process recommendations
for (let i = 0; i < recs.results.length && i < 20; i++) { for (let i = 0; i < recs.results.length && i < 20; i++) {
const r = recs.results[i]; const r = recs.results[i];
+14
View File
@@ -8,10 +8,13 @@ import {
webhookConnections, webhookConnections,
webhookEventLog, webhookEventLog,
} from "@/lib/db/schema"; } from "@/lib/db/schema";
import { createLogger } from "@/lib/logger";
import { findByExternalId, searchTv } from "@/lib/tmdb/client"; import { findByExternalId, searchTv } from "@/lib/tmdb/client";
import { importTitle } from "./metadata"; import { importTitle } from "./metadata";
import { logEpisodeWatch, logMovieWatch } from "./tracking"; import { logEpisodeWatch, logMovieWatch } from "./tracking";
const log = createLogger("webhooks");
// ─── Types ────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────
export interface WebhookEvent { export interface WebhookEvent {
@@ -301,10 +304,14 @@ export async function processWebhook(
provider: "plex" | "jellyfin" | "emby", provider: "plex" | "jellyfin" | "emby",
event: WebhookEvent, event: WebhookEvent,
): Promise<{ status: "success" | "ignored" | "error"; message: string }> { ): Promise<{ status: "success" | "ignored" | "error"; message: string }> {
log.info(`Received ${provider} webhook: ${event.mediaType} "${event.title}"`);
try { try {
if (event.mediaType === "movie") { if (event.mediaType === "movie") {
const tmdbId = await resolveMovieTmdbId(event); const tmdbId = await resolveMovieTmdbId(event);
if (!tmdbId) { if (!tmdbId) {
log.warn(
`Could not resolve TMDB ID for movie "${event.title}" from ${provider}`,
);
logEvent( logEvent(
connectionId, connectionId,
event, event,
@@ -321,6 +328,7 @@ export async function processWebhook(
} }
if (isDuplicateMovieWatch(userId, title.id)) { if (isDuplicateMovieWatch(userId, title.id)) {
log.debug(`Duplicate movie watch ignored: "${event.title}"`);
logEvent( logEvent(
connectionId, connectionId,
event, event,
@@ -331,6 +339,7 @@ export async function processWebhook(
} }
logMovieWatch(userId, title.id, provider); logMovieWatch(userId, title.id, provider);
log.info(`Logged movie watch: "${event.title}" (TMDB ${tmdbId})`);
logEvent(connectionId, event, "success"); logEvent(connectionId, event, "success");
return { status: "success", message: `Logged watch for ${event.title}` }; return { status: "success", message: `Logged watch for ${event.title}` };
} }
@@ -338,6 +347,7 @@ export async function processWebhook(
if (event.mediaType === "episode") { if (event.mediaType === "episode") {
const resolved = await resolveEpisode(event); const resolved = await resolveEpisode(event);
if (!resolved) { if (!resolved) {
log.warn(`Could not resolve episode "${event.title}" from ${provider}`);
logEvent(connectionId, event, "error", "Could not resolve episode"); logEvent(connectionId, event, "error", "Could not resolve episode");
return { status: "error", message: "Could not resolve episode" }; return { status: "error", message: "Could not resolve episode" };
} }
@@ -398,6 +408,7 @@ export async function processWebhook(
} }
if (isDuplicateEpisodeWatch(userId, episode.id)) { if (isDuplicateEpisodeWatch(userId, episode.id)) {
log.debug(`Duplicate episode watch ignored: "${event.title}"`);
logEvent( logEvent(
connectionId, connectionId,
event, event,
@@ -408,6 +419,9 @@ export async function processWebhook(
} }
logEpisodeWatch(userId, episode.id, provider); logEpisodeWatch(userId, episode.id, provider);
log.info(
`Logged episode watch: "${event.title}" S${resolved.seasonNumber}E${resolved.episodeNumber}`,
);
logEvent(connectionId, event, "success"); logEvent(connectionId, event, "success");
return { status: "success", message: `Logged watch for ${event.title}` }; return { status: "success", message: `Logged watch for ${event.title}` };
} }
+9
View File
@@ -1,3 +1,4 @@
import { createLogger } from "@/lib/logger";
import type { import type {
TmdbFindResult, TmdbFindResult,
TmdbGenreListResponse, TmdbGenreListResponse,
@@ -9,6 +10,8 @@ import type {
TmdbWatchProviderResponse, TmdbWatchProviderResponse,
} from "./types"; } from "./types";
const log = createLogger("tmdb");
const BASE_URL = const BASE_URL =
process.env.TMDB_API_BASE_URL || "https://api.themoviedb.org/3"; process.env.TMDB_API_BASE_URL || "https://api.themoviedb.org/3";
function getApiKey() { function getApiKey() {
@@ -29,6 +32,7 @@ async function tmdbFetch<T>(
} }
} }
const start = performance.now();
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), {
...fetchOptions, ...fetchOptions,
headers: { headers: {
@@ -37,11 +41,16 @@ async function tmdbFetch<T>(
...fetchOptions?.headers, ...fetchOptions?.headers,
}, },
}); });
const elapsed = Math.round(performance.now() - start);
if (!res.ok) { if (!res.ok) {
log.warn(
`${url.toString()} -> ${res.status} ${res.statusText} (${elapsed}ms)`,
);
throw new Error(`TMDB API error: ${res.status} ${res.statusText}`); throw new Error(`TMDB API error: ${res.status} ${res.statusText}`);
} }
log.debug(`${url.toString()} -> ${res.status} (${elapsed}ms)`);
return res.json() as Promise<T>; return res.json() as Promise<T>;
} }