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