mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 00:25:38 -04:00
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:
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user