mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
refactor: optimize cron scheduling, DB maintenance, and TMDB reliability
- Skip recommendations refresh for titles fetched within the last 7 days via `getTitlesWithFreshRecommendations`; only stale titles are re-fetched - Replace `Promise.allSettled` in `refreshTvChildren` with a `mapWithConcurrency` helper (concurrency = 5) to stay within TMDB's rate limit - Add 30s request timeout middleware to TMDB client to prevent indefinitely hung fetches - Expand `optimizeDb` weekly job to prune 30-day-old cron runs, integration events, expired sessions, and expired verifications - Add `wal_checkpoint(TRUNCATE)` to `optimizeDatabase` so the WAL is flushed before `PRAGMA optimize` - Add `recoverStaleImportJobs` called on server startup to mark any `pending`/`running` import jobs as errored after a crash/restart - Return `false` from `triggerJob` immediately if the job is already running
This commit is contained in:
+27
-3
@@ -15,6 +15,7 @@ import {
|
||||
getThumbhashBackfillTitleIds,
|
||||
getTitleByIdForCron,
|
||||
getTitleIdsWithStaleSeasons,
|
||||
getTitlesWithFreshRecommendations,
|
||||
startCronRun,
|
||||
} from "@sofa/core/cron";
|
||||
import {
|
||||
@@ -87,10 +88,11 @@ export function getJobSchedules(): {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Manually trigger a job by name. Returns false if job not found. */
|
||||
/** Manually trigger a job by name. Returns false if job not found or already running. */
|
||||
export async function triggerJob(name: string): Promise<boolean> {
|
||||
const job = jobs.get(name);
|
||||
if (!job) return false;
|
||||
if (job.isBusy()) return false;
|
||||
await job.trigger();
|
||||
return true;
|
||||
}
|
||||
@@ -139,9 +141,14 @@ async function refreshAvailabilityJob() {
|
||||
|
||||
async function refreshRecommendationsJob() {
|
||||
const libraryIds = getLibraryTitleIds();
|
||||
log.debug(`Refreshing recommendations for ${libraryIds.length} library titles`);
|
||||
const stale = new Date(Date.now() - 7 * DAY);
|
||||
const fresh = getTitlesWithFreshRecommendations(libraryIds, stale);
|
||||
const staleIds = libraryIds.filter((id) => !fresh.has(id));
|
||||
log.debug(
|
||||
`Refreshing recommendations for ${staleIds.length} of ${libraryIds.length} library titles`,
|
||||
);
|
||||
|
||||
for (const titleId of libraryIds) {
|
||||
for (const titleId of staleIds) {
|
||||
await refreshRecommendations(titleId);
|
||||
await Bun.sleep(RATE_LIMIT_MS);
|
||||
}
|
||||
@@ -300,6 +307,23 @@ export function startJobs() {
|
||||
});
|
||||
schedule("optimizeDb", "0 4 * * 0", async () => {
|
||||
const { optimizeDatabase } = await import("@sofa/db/client");
|
||||
const { deleteOldCronRuns } = await import("@sofa/db/queries/cron");
|
||||
const { deleteOldIntegrationEvents } = await import("@sofa/db/queries/webhooks");
|
||||
const { deleteExpiredSessions, deleteExpiredVerifications } =
|
||||
await import("@sofa/db/queries/auth-cleanup");
|
||||
|
||||
const retentionDate = new Date(Date.now() - 30 * DAY);
|
||||
const cronDeleted = deleteOldCronRuns(retentionDate);
|
||||
const eventsDeleted = deleteOldIntegrationEvents(retentionDate);
|
||||
const sessionsDeleted = deleteExpiredSessions();
|
||||
const verificationsDeleted = deleteExpiredVerifications();
|
||||
|
||||
if (cronDeleted + eventsDeleted + sessionsDeleted + verificationsDeleted > 0) {
|
||||
log.info(
|
||||
`Pruned ${cronDeleted} cron runs, ${eventsDeleted} integration events, ${sessionsDeleted} sessions, ${verificationsDeleted} verifications`,
|
||||
);
|
||||
}
|
||||
|
||||
optimizeDatabase();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ensureImageDirs, imageCacheEnabled } from "@sofa/core/image-cache";
|
||||
import { registerJobScheduleProvider } from "@sofa/core/system-health";
|
||||
import { closeDatabase, isDatabaseAccessBlocked } from "@sofa/db/client";
|
||||
import { runMigrations } from "@sofa/db/migrate";
|
||||
import { recoverStaleImportJobs } from "@sofa/db/queries/imports";
|
||||
import { createLogger } from "@sofa/logger";
|
||||
|
||||
import { getJobSchedules, startJobs, stopJobs } from "./cron";
|
||||
@@ -35,6 +36,12 @@ await ensureBackupDir();
|
||||
// Run database migrations
|
||||
runMigrations();
|
||||
|
||||
// Recover import jobs left in running/pending state from a previous crash
|
||||
const recoveredJobs = recoverStaleImportJobs();
|
||||
if (recoveredJobs > 0) {
|
||||
log.info(`Recovered ${recoveredJobs} stale import job(s) from previous shutdown`);
|
||||
}
|
||||
|
||||
// Wire up job schedule provider for system health
|
||||
registerJobScheduleProvider(getJobSchedules);
|
||||
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../.oxlintrc.json"],
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "nextjs", "import"],
|
||||
"plugins": ["oxc", "eslint", "typescript", "react", "nextjs", "import", "unicorn"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "warn"
|
||||
|
||||
@@ -10,11 +10,11 @@ Sofa includes optional, privacy-focused telemetry to help understand how the pro
|
||||
|
||||
Sofa has two separate telemetry systems depending on the platform:
|
||||
|
||||
| | Server | Mobile App | Web App |
|
||||
| ----------- | ---------------- | ------------ | ------- |
|
||||
| **System** | Custom reporting | PostHog | None |
|
||||
| **Default** | Disabled | Disabled | N/A |
|
||||
| **Opt-in** | Admin toggle | User toggle | N/A |
|
||||
| | Server | Mobile App | Web App |
|
||||
| ----------- | ---------------- | ----------- | ------- |
|
||||
| **System** | Custom reporting | PostHog | None |
|
||||
| **Default** | Disabled | Disabled | N/A |
|
||||
| **Opt-in** | Admin toggle | User toggle | N/A |
|
||||
|
||||
The web app has **zero analytics** — no tracking scripts, no third-party services.
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ icon: Compass
|
||||
|
||||
The dashboard is your home screen after logging in. It shows a personalized overview of your tracking activity.
|
||||
|
||||
- **Stats** — Cards showing movies watched, episodes watched, library size, and completed titles. Each card has a period selector (today, this week, this month, this year).
|
||||
- **Stats** — Cards showing movies watched, episodes watched, library size, and completed titles. Each card has a period selector (today, this week, this month, this year) and a sparkline chart of your watch history.
|
||||
- **Continue Watching** — TV shows you're currently watching, with the next unwatched episode highlighted. Click a show to jump to its detail page and pick up where you left off.
|
||||
- **Upcoming** — Episodes and releases airing in the next 7 days for titles in your library. Click "View all" to open the full upcoming page with episodes grouped by date (Today, Tomorrow, This Week, Later).
|
||||
- **In Your Library** — A grid of all titles you're tracking, regardless of status.
|
||||
- **Recommended for You** — Personalized suggestions based on titles you've watched and rated highly.
|
||||
|
||||
@@ -19,23 +20,31 @@ If your library is empty, the dashboard shows an invitation to start exploring.
|
||||
|
||||
### The Status System
|
||||
|
||||
Every title in your library has one of three statuses:
|
||||
Every title in your library has a status:
|
||||
|
||||
| Status | Meaning |
|
||||
| --------------- | ------------------------------ |
|
||||
| **Watchlist** | You plan to watch this |
|
||||
| **In Progress** | You're currently watching this |
|
||||
| **Completed** | You've finished watching this |
|
||||
| Status | Meaning |
|
||||
| ------------- | ------------------------------------------------------------------------- |
|
||||
| **Watchlist** | You plan to watch this |
|
||||
| **Watching** | You're currently watching this (TV shows only) |
|
||||
| **Caught Up** | You've watched all aired episodes, but the show is still airing (TV only) |
|
||||
| **Completed** | You've finished watching this |
|
||||
|
||||
Set a status from any title's detail page by clicking the status button, or press <kbd>W</kbd> to cycle through statuses.
|
||||
Click the status button on a title page to add it to your watchlist. If the title is already in your library, hovering the button reveals a "Remove" action. Press <kbd>W</kbd> to toggle the title in or out of your watchlist.
|
||||
|
||||
<Callout type="info">
|
||||
**Caught Up** vs **Completed** is automatic — when you've watched every episode of a TV show that
|
||||
is still airing (Returning Series or In Production on TMDB), Sofa shows **Caught Up** instead of
|
||||
**Completed**. Once the show ends, it switches to **Completed** automatically.
|
||||
</Callout>
|
||||
|
||||
### Automatic Status Transitions
|
||||
|
||||
Sofa automatically updates statuses as you log watches so you don't have to manage them manually:
|
||||
|
||||
- **First episode watched** on a TV show → status changes to **In Progress** (if it was on your Watchlist or had no status)
|
||||
- **All episodes watched** on a TV show → status changes to **Completed**
|
||||
- **Unwatch an episode** on a completed show → status drops back to **In Progress**
|
||||
- **First episode watched** on a TV show → status changes to **Watching** (if it was on your Watchlist or had no status)
|
||||
- **All episodes watched** on a TV show → status changes to **Caught Up** (if the show is still airing) or **Completed** (if the show has ended)
|
||||
- **Unwatch an episode** on a completed show → status drops back to **Watching**
|
||||
- **Unwatch all episodes** on a show → status drops back to **Watchlist**
|
||||
- **Mark a movie as watched** → status changes to **Completed**
|
||||
|
||||
These same transitions apply when watches are logged automatically via [webhook integrations](/docs/integrations) (Plex, Jellyfin, Emby) or [imported from another app](/docs/integrations/import) (Trakt, Simkl, Letterboxd).
|
||||
@@ -118,7 +127,9 @@ Press <kbd>?</kbd> anywhere in the app to see the full shortcut reference. Here'
|
||||
| Global | `?` | Show keyboard shortcuts |
|
||||
| Navigation | `g` then `h` | Go to dashboard |
|
||||
| Navigation | `g` then `e` | Go to explore |
|
||||
| Title page | `w` | Cycle watch status |
|
||||
| Navigation | `g` then `u` | Go to upcoming |
|
||||
| Navigation | `g` then `s` | Go to settings |
|
||||
| Title page | `w` | Toggle watchlist |
|
||||
| Title page | `m` | Mark movie as watched |
|
||||
| Title page | `Escape` | Go back |
|
||||
| Title page | `1`–`5` | Rate 1–5 stars |
|
||||
|
||||
@@ -31,7 +31,7 @@ const config = {
|
||||
source: "/testflight",
|
||||
destination: "https://testflight.apple.com/join/tjSddcaZ",
|
||||
permanent: false,
|
||||
}
|
||||
},
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
|
||||
+5
-5
@@ -3,15 +3,15 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"start": "next start",
|
||||
"build": "bun --bun next build",
|
||||
"dev": "bun --bun next dev",
|
||||
"start": "bun --bun next start",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt",
|
||||
"format:check": "oxfmt --check",
|
||||
"check-types": "fumadocs-mdx && next typegen && tsc --noEmit",
|
||||
"check-types": "bun --bun fumadocs-mdx && bun --bun next typegen && tsc --noEmit",
|
||||
"generate:api-docs": "bun scripts/generate-api-docs.ts",
|
||||
"postinstall": "fumadocs-mdx"
|
||||
"postinstall": "bun --bun fumadocs-mdx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@takumi-rs/image-response": "^0.73.1",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"bunVersion": "1.x"
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getStaleNonLibraryTitles,
|
||||
getTitleByIdForCron,
|
||||
getTitleIdsWithStaleSeasons,
|
||||
getTitlesWithFreshRecommendations,
|
||||
getTitlesWithStaleOffers,
|
||||
getTitlesWithStaleOffersFetchedBefore,
|
||||
insertCronRunReturning,
|
||||
@@ -53,4 +54,5 @@ export {
|
||||
getReturningTvShows,
|
||||
getTitleByIdForCron,
|
||||
getTitleIdsWithStaleSeasons,
|
||||
getTitlesWithFreshRecommendations,
|
||||
};
|
||||
|
||||
@@ -63,6 +63,27 @@ import {
|
||||
|
||||
const log = createLogger("metadata");
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
fn: (item: T) => Promise<R>,
|
||||
concurrency: number,
|
||||
): Promise<PromiseSettledResult<R>[]> {
|
||||
const results: PromiseSettledResult<R>[] = Array.from({ length: items.length });
|
||||
let i = 0;
|
||||
async function worker() {
|
||||
while (i < items.length) {
|
||||
const idx = i++;
|
||||
try {
|
||||
results[idx] = { status: "fulfilled", value: await fn(items[idx]) };
|
||||
} catch (reason) {
|
||||
results[idx] = { status: "rejected", reason };
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
|
||||
export function updateTitleWithArtInvalidation(
|
||||
title: Pick<
|
||||
typeof titles.$inferSelect,
|
||||
@@ -340,10 +361,12 @@ export async function refreshTitle(titleId: string) {
|
||||
}
|
||||
|
||||
export async function refreshTvChildren(titleId: string, tmdbId: number, numberOfSeasons: number) {
|
||||
// Fetch all seasons from TMDB concurrently (~40 req/s rate limit)
|
||||
// Fetch seasons with limited concurrency to stay within TMDB's 40 req/s limit
|
||||
const seasonNumbers = Array.from({ length: numberOfSeasons }, (_, i) => i + 1);
|
||||
const fetched = await Promise.allSettled(
|
||||
seasonNumbers.map((sn) => getTvSeasonDetails(tmdbId, sn)),
|
||||
const fetched = await mapWithConcurrency(
|
||||
seasonNumbers,
|
||||
(sn) => getTvSeasonDetails(tmdbId, sn),
|
||||
5,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -86,8 +86,9 @@ export const db = new Proxy({} as ReturnType<typeof drizzle>, {
|
||||
},
|
||||
});
|
||||
|
||||
/** Run PRAGMA optimize to refresh query planner statistics. */
|
||||
/** Checkpoint the WAL and refresh query planner statistics. */
|
||||
export function optimizeDatabase() {
|
||||
getClient().run("PRAGMA wal_checkpoint(TRUNCATE)");
|
||||
getClient().run("PRAGMA optimize");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { lt } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import { session, verification } from "../schema";
|
||||
|
||||
export function deleteExpiredSessions(): number {
|
||||
const now = new Date();
|
||||
const count = db
|
||||
.select({ id: session.id })
|
||||
.from(session)
|
||||
.where(lt(session.expiresAt, now))
|
||||
.all().length;
|
||||
if (count === 0) return 0;
|
||||
db.delete(session).where(lt(session.expiresAt, now)).run();
|
||||
return count;
|
||||
}
|
||||
|
||||
export function deleteExpiredVerifications(): number {
|
||||
const now = new Date();
|
||||
const count = db
|
||||
.select({ id: verification.id })
|
||||
.from(verification)
|
||||
.where(lt(verification.expiresAt, now))
|
||||
.all().length;
|
||||
if (count === 0) return 0;
|
||||
db.delete(verification).where(lt(verification.expiresAt, now)).run();
|
||||
return count;
|
||||
}
|
||||
@@ -121,7 +121,11 @@ export function purgeShellTitlesTransaction(): PurgeResult {
|
||||
|
||||
// After title cascade deletes, collect orphaned person images then delete persons
|
||||
orphanedImages.push(...collectOrphanedPersonImages());
|
||||
return { deletedTitles: toDelete.length, deletedPersons: purgeOrphanedPersons(), orphanedImages };
|
||||
return {
|
||||
deletedTitles: toDelete.length,
|
||||
deletedPersons: purgeOrphanedPersons(),
|
||||
orphanedImages,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, inArray, isNotNull, lt, or } from "drizzle-orm";
|
||||
import { and, eq, gte, inArray, isNotNull, lt, or } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import {
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
cronRuns,
|
||||
seasons,
|
||||
titleCast,
|
||||
titleRecommendations,
|
||||
titles,
|
||||
userTitleStatus,
|
||||
} from "../schema";
|
||||
@@ -125,3 +126,42 @@ export function getTitleByIdForCron(titleId: string) {
|
||||
export function getCastEntryForTitle(titleId: string) {
|
||||
return db.select().from(titleCast).where(eq(titleCast.titleId, titleId)).limit(1).get();
|
||||
}
|
||||
|
||||
export function deleteOldCronRuns(beforeDate: Date): number {
|
||||
const old = db
|
||||
.select({ id: cronRuns.id })
|
||||
.from(cronRuns)
|
||||
.where(lt(cronRuns.startedAt, beforeDate))
|
||||
.all();
|
||||
if (old.length === 0) return 0;
|
||||
const ids = old.map((r) => r.id);
|
||||
for (let i = 0; i < ids.length; i += 500) {
|
||||
db.delete(cronRuns)
|
||||
.where(inArray(cronRuns.id, ids.slice(i, i + 500)))
|
||||
.run();
|
||||
}
|
||||
return old.length;
|
||||
}
|
||||
|
||||
export function getTitlesWithFreshRecommendations(
|
||||
titleIds: string[],
|
||||
sinceDate: Date,
|
||||
): Set<string> {
|
||||
if (titleIds.length === 0) return new Set();
|
||||
|
||||
return new Set(
|
||||
db
|
||||
.select({ titleId: titleRecommendations.titleId })
|
||||
.from(titleRecommendations)
|
||||
.where(
|
||||
and(
|
||||
inArray(titleRecommendations.titleId, titleIds),
|
||||
// All recs for a title share the same lastFetchedAt, so any row suffices
|
||||
gte(titleRecommendations.lastFetchedAt, sinceDate),
|
||||
),
|
||||
)
|
||||
.groupBy(titleRecommendations.titleId)
|
||||
.all()
|
||||
.map((r) => r.titleId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,3 +86,25 @@ export function getActiveImportJobForUser(userId: string) {
|
||||
.where(and(eq(importJobs.userId, userId), inArray(importJobs.status, ["pending", "running"])))
|
||||
.get();
|
||||
}
|
||||
|
||||
/** Mark any running/pending import jobs as errored — called on server startup to recover from crashes. */
|
||||
export function recoverStaleImportJobs(): number {
|
||||
const stale = db
|
||||
.select({ id: importJobs.id })
|
||||
.from(importJobs)
|
||||
.where(inArray(importJobs.status, ["pending", "running"]))
|
||||
.all();
|
||||
if (stale.length === 0) return 0;
|
||||
const now = new Date();
|
||||
for (const { id } of stale) {
|
||||
db.update(importJobs)
|
||||
.set({
|
||||
status: "error",
|
||||
finishedAt: now,
|
||||
errors: JSON.stringify(["Import interrupted by server restart"]),
|
||||
})
|
||||
.where(eq(importJobs.id, id))
|
||||
.run();
|
||||
}
|
||||
return stale.length;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, gte } from "drizzle-orm";
|
||||
import { and, eq, gte, inArray, lt } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import { integrationEvents, integrations, userEpisodeWatches, userMovieWatches } from "../schema";
|
||||
@@ -41,3 +41,19 @@ export function updateIntegrationLastEvent(connectionId: string): void {
|
||||
.where(eq(integrations.id, connectionId))
|
||||
.run();
|
||||
}
|
||||
|
||||
export function deleteOldIntegrationEvents(beforeDate: Date): number {
|
||||
const old = db
|
||||
.select({ id: integrationEvents.id })
|
||||
.from(integrationEvents)
|
||||
.where(lt(integrationEvents.receivedAt, beforeDate))
|
||||
.all();
|
||||
if (old.length === 0) return 0;
|
||||
const ids = old.map((r) => r.id);
|
||||
for (let i = 0; i < ids.length; i += 500) {
|
||||
db.delete(integrationEvents)
|
||||
.where(inArray(integrationEvents.id, ids.slice(i, i + 500)))
|
||||
.run();
|
||||
}
|
||||
return old.length;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,25 @@ const baseUrlRewriteMiddleware: Middleware | null = isCustomBase
|
||||
}
|
||||
: null;
|
||||
|
||||
const TMDB_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
const requestTimeouts = new WeakMap<Request, Timer>();
|
||||
|
||||
const timeoutMiddleware: Middleware = {
|
||||
async onRequest({ request }) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TMDB_REQUEST_TIMEOUT_MS);
|
||||
const newRequest = new Request(request, { signal: controller.signal });
|
||||
requestTimeouts.set(newRequest, timeout);
|
||||
return newRequest;
|
||||
},
|
||||
async onResponse({ request }) {
|
||||
const timeout = requestTimeouts.get(request);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
const authMiddleware: Middleware = {
|
||||
async onRequest({ request }) {
|
||||
request.headers.set("Authorization", `Bearer ${getApiKey()}`);
|
||||
@@ -155,7 +174,7 @@ const loggingMiddleware: Middleware = {
|
||||
|
||||
const client = createClient<paths>({ baseUrl });
|
||||
if (baseUrlRewriteMiddleware) client.use(baseUrlRewriteMiddleware);
|
||||
client.use(authMiddleware, loggingMiddleware);
|
||||
client.use(timeoutMiddleware, authMiddleware, loggingMiddleware);
|
||||
|
||||
// ─── Search ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user