diff --git a/apps/server/src/cron.ts b/apps/server/src/cron.ts index b704e45..75fe351 100644 --- a/apps/server/src/cron.ts +++ b/apps/server/src/cron.ts @@ -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 { 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(); }); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 79fb8cd..7ded166 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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); diff --git a/docs/.oxlintrc.json b/docs/.oxlintrc.json index 8736251..9cb1971 100644 --- a/docs/.oxlintrc.json +++ b/docs/.oxlintrc.json @@ -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" diff --git a/docs/content/docs/telemetry.mdx b/docs/content/docs/telemetry.mdx index ec92fcd..b527b82 100644 --- a/docs/content/docs/telemetry.mdx +++ b/docs/content/docs/telemetry.mdx @@ -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. diff --git a/docs/content/docs/using-sofa.mdx b/docs/content/docs/using-sofa.mdx index e254713..9c36725 100644 --- a/docs/content/docs/using-sofa.mdx +++ b/docs/content/docs/using-sofa.mdx @@ -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 W 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 W to toggle the title in or out of your watchlist. + + + **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. + ### 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 ? 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 | diff --git a/docs/next.config.mjs b/docs/next.config.mjs index 36c5478..26d76b4 100644 --- a/docs/next.config.mjs +++ b/docs/next.config.mjs @@ -31,7 +31,7 @@ const config = { source: "/testflight", destination: "https://testflight.apple.com/join/tjSddcaZ", permanent: false, - } + }, ]; }, async rewrites() { diff --git a/docs/package.json b/docs/package.json index 4481635..3370374 100644 --- a/docs/package.json +++ b/docs/package.json @@ -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", diff --git a/docs/vercel.json b/docs/vercel.json new file mode 100644 index 0000000..2b6ae97 --- /dev/null +++ b/docs/vercel.json @@ -0,0 +1,3 @@ +{ + "bunVersion": "1.x" +} diff --git a/packages/core/src/cron.ts b/packages/core/src/cron.ts index 050b96e..0b4a89b 100644 --- a/packages/core/src/cron.ts +++ b/packages/core/src/cron.ts @@ -6,6 +6,7 @@ import { getStaleNonLibraryTitles, getTitleByIdForCron, getTitleIdsWithStaleSeasons, + getTitlesWithFreshRecommendations, getTitlesWithStaleOffers, getTitlesWithStaleOffersFetchedBefore, insertCronRunReturning, @@ -53,4 +54,5 @@ export { getReturningTvShows, getTitleByIdForCron, getTitleIdsWithStaleSeasons, + getTitlesWithFreshRecommendations, }; diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 83f9d0b..5236e88 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -63,6 +63,27 @@ import { const log = createLogger("metadata"); +async function mapWithConcurrency( + items: T[], + fn: (item: T) => Promise, + concurrency: number, +): Promise[]> { + const results: PromiseSettledResult[] = 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(); diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 556fc59..c2d4a54 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -86,8 +86,9 @@ export const db = new Proxy({} as ReturnType, { }, }); -/** 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"); } diff --git a/packages/db/src/queries/auth-cleanup.ts b/packages/db/src/queries/auth-cleanup.ts new file mode 100644 index 0000000..2678c50 --- /dev/null +++ b/packages/db/src/queries/auth-cleanup.ts @@ -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; +} diff --git a/packages/db/src/queries/cache.ts b/packages/db/src/queries/cache.ts index 6d7e4a1..3b82083 100644 --- a/packages/db/src/queries/cache.ts +++ b/packages/db/src/queries/cache.ts @@ -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, + }; }); } diff --git a/packages/db/src/queries/cron.ts b/packages/db/src/queries/cron.ts index 2229821..529a418 100644 --- a/packages/db/src/queries/cron.ts +++ b/packages/db/src/queries/cron.ts @@ -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 { + 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), + ); +} diff --git a/packages/db/src/queries/imports.ts b/packages/db/src/queries/imports.ts index d8f368b..18501f1 100644 --- a/packages/db/src/queries/imports.ts +++ b/packages/db/src/queries/imports.ts @@ -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; +} diff --git a/packages/db/src/queries/webhooks.ts b/packages/db/src/queries/webhooks.ts index f1e4023..3c1dbca 100644 --- a/packages/db/src/queries/webhooks.ts +++ b/packages/db/src/queries/webhooks.ts @@ -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; +} diff --git a/packages/tmdb/src/client.ts b/packages/tmdb/src/client.ts index 44357fd..6f99e91 100644 --- a/packages/tmdb/src/client.ts +++ b/packages/tmdb/src/client.ts @@ -126,6 +126,25 @@ const baseUrlRewriteMiddleware: Middleware | null = isCustomBase } : null; +const TMDB_REQUEST_TIMEOUT_MS = 30_000; + +const requestTimeouts = new WeakMap(); + +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({ baseUrl }); if (baseUrlRewriteMiddleware) client.use(baseUrlRewriteMiddleware); -client.use(authMiddleware, loggingMiddleware); +client.use(timeoutMiddleware, authMiddleware, loggingMiddleware); // ─── Search ─────────────────────────────────────────────────────────