diff --git a/components/title-card.tsx b/components/title-card.tsx index 5fc3a0d..bba12e5 100644 --- a/components/title-card.tsx +++ b/components/title-card.tsx @@ -255,7 +255,7 @@ function CardInner({ /** Linked title card for library grids, recommendations, dashboards */ export function TitleCard({ id, - tmdbId, + tmdbId: _tmdbId, type, title, posterPath, diff --git a/lib/cron.ts b/lib/cron.ts index 26cc9f1..f9f8932 100644 --- a/lib/cron.ts +++ b/lib/cron.ts @@ -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); } diff --git a/lib/services/availability.ts b/lib/services/availability.ts index c4736ea..eea0162 100644 --- a/lib/services/availability.ts +++ b/lib/services/availability.ts @@ -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) { diff --git a/lib/services/colors.ts b/lib/services/colors.ts index c0824d4..5af6d2a 100644 --- a/lib/services/colors.ts +++ b/lib/services/colors.ts @@ -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); diff --git a/lib/services/image-cache.ts b/lib/services/image-cache.ts index 10e0870..51c4f8a 100644 --- a/lib/services/image-cache.ts +++ b/lib/services/image-cache.ts @@ -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); } diff --git a/lib/services/metadata.ts b/lib/services/metadata.ts index 132dfd3..0ad9d1d 100644 --- a/lib/services/metadata.ts +++ b/lib/services/metadata.ts @@ -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]; diff --git a/lib/services/webhooks.ts b/lib/services/webhooks.ts index 9d079c2..3298346 100644 --- a/lib/services/webhooks.ts +++ b/lib/services/webhooks.ts @@ -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}` }; } diff --git a/lib/tmdb/client.ts b/lib/tmdb/client.ts index 3e392d2..dc1d14d 100644 --- a/lib/tmdb/client.ts +++ b/lib/tmdb/client.ts @@ -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( } } + const start = performance.now(); const res = await fetch(url.toString(), { ...fetchOptions, headers: { @@ -37,11 +41,16 @@ async function tmdbFetch( ...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; }