refactor: enforce layered architecture by extracting all DB queries into @sofa/db/queries/*

- Create 18 new query modules under `packages/db/src/queries/` covering every domain (availability, cache, colors, credits, cron, discovery, image-cache, imports, integrations, lists, metadata, person, settings, system-health, thumbhash, title, tracking, webhooks)
- Remove all direct `db` client imports and raw Drizzle expressions from `@sofa/core` services and `apps/server` procedures/cron; replace with named query functions
- Extract cron DB helpers (`startCronRun`, `completeCronRun`, `failCronRun`, `getLibraryTitleIds`, `getStaleLibraryTitles`, etc.) into `packages/core/src/cron.ts`
- Extract integration DB helpers into `packages/core/src/integrations.ts`
- Add `drizzle-orm` as a direct dependency of `@sofa/db` and remove it from `@sofa/core`
- Update `AGENTS.md` to document the strict layered architecture rule
This commit is contained in:
2026-03-18 17:31:18 -04:00
parent 3a0374c825
commit 3c1b7cfe11
55 changed files with 3048 additions and 2079 deletions
+8 -3
View File
@@ -47,8 +47,8 @@ couch-potato/
│ ├── api/ # @sofa/api — oRPC contract + Zod schemas (shared, JIT)
│ ├── auth/ # @sofa/auth — Better Auth server config (JIT)
│ ├── config/ # @sofa/config — Path constants + TMDB URLs from env (JIT)
│ ├── core/ # @sofa/core — Business logic services (JIT)
│ ├── db/ # @sofa/db — Drizzle schema, client, migrations (JIT)
│ ├── core/ # @sofa/core — Business logic services (JIT, DB-agnostic)
│ ├── db/ # @sofa/db — Drizzle schema, client, queries, migrations (JIT)
│ ├── logger/ # @sofa/logger — Pino-based structured logging (JIT)
│ └── tmdb/ # @sofa/tmdb — TMDB API client + image URL helper (JIT)
├── .oxlintrc.json
@@ -87,7 +87,8 @@ Path aliases: `@/*` maps to `src/` in both `apps/web/` and `apps/native/`.
Cross-package imports:
- `@sofa/api/contract`, `@sofa/api/schemas` — Contract and Zod types
- `@sofa/db/client`, `@sofa/db/schema`, `@sofa/db/helpers`, `@sofa/db/migrate`, `@sofa/db/test-utils`
- `@sofa/db/queries/*` — Query layer (e.g., `@sofa/db/queries/tracking`, `@sofa/db/queries/metadata`)
- `@sofa/db/client`, `@sofa/db/schema`, `@sofa/db/utils`, `@sofa/db/migrate`, `@sofa/db/test-utils`
- `@sofa/tmdb/client`, `@sofa/tmdb/image`
- `@sofa/core/metadata`, `@sofa/core/tracking`, `@sofa/core/imports`, etc.
- `@sofa/auth/server`, `@sofa/auth/config`
@@ -96,6 +97,10 @@ Cross-package imports:
### Key patterns
**Layered architecture** — Strict separation: `apps/server/ → @sofa/core/* → @sofa/db/queries/* → @sofa/db/client`. Server procedures/routes call core services for all business logic. Core services call query functions for all DB access. Core must **never** import `@sofa/db/client` directly. Type-only imports from `@sofa/db/schema` are allowed in core (for `$inferInsert`/`$inferSelect`), but runtime imports are not.
**Query layer** — All Drizzle ORM queries live in `packages/db/src/queries/`, organized by domain (tracking, metadata, discovery, etc.). Plain exported functions, transactions hidden as implementation details. Wildcard export: `"./queries/*": "./src/queries/*.ts"`.
**oRPC queries** use `orpc.*.queryOptions()` with TanStack Query. **Mutations** use `client.*()` directly. **Route loaders** prefetch via `queryClient.ensureQueryData()`.
**Auth guards** — Web routes use `beforeLoad` + `authClient.getSession()`. API procedures use auth middleware that calls `auth.api.getSession()`.
+25 -161
View File
@@ -3,6 +3,20 @@ import { Cron } from "croner";
import { refreshAvailability } from "@sofa/core/availability";
import { createBackup, ensureBackupDir, pruneBackups } from "@sofa/core/backup";
import { refreshCredits, syncCastProfileThumbHashes } from "@sofa/core/credits";
import {
completeCronRun,
failCronRun,
getCastEntryForTitle,
getLibraryTitleIds,
getReturningTvShows,
getStaleAvailabilityTitles,
getStaleLibraryTitles,
getStaleNonLibraryTitlesForRefresh,
getThumbhashBackfillTitleIds,
getTitleByIdForCron,
getTitleIdsWithStaleSeasons,
startCronRun,
} from "@sofa/core/cron";
import {
cacheEpisodeStills,
cacheImagesForTitle,
@@ -20,18 +34,6 @@ import { getSetting } from "@sofa/core/settings";
import { performTelemetryReport } from "@sofa/core/telemetry";
import { generateTitleBackdropThumbHash, generateTitlePosterThumbHash } from "@sofa/core/thumbhash";
import { performUpdateCheck } from "@sofa/core/update-check";
import { db } from "@sofa/db/client";
import { and, eq, inArray, isNotNull, lt, or, sql } from "@sofa/db/helpers";
import {
availabilityOffers,
cronRuns,
episodes,
persons,
seasons,
titleCast,
titles,
userTitleStatus,
} from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { getTvDetails } from "@sofa/tmdb/client";
@@ -57,30 +59,15 @@ function schedule(name: string, cron: string, handler: () => Promise<void>) {
new Cron(cron, { name, protect: true }, async () => {
log.info(`Running job: ${name}`);
const startMs = performance.now();
const run = db
.insert(cronRuns)
.values({ jobName: name, status: "running", startedAt: new Date() })
.returning()
.get();
const run = startCronRun(name);
try {
await handler();
const durationMs = Math.round(performance.now() - startMs);
db.update(cronRuns)
.set({ status: "success", finishedAt: new Date(), durationMs })
.where(eq(cronRuns.id, run.id))
.run();
completeCronRun(run.id, durationMs);
log.info(`Completed job: ${name} (${durationMs}ms)`);
} catch (err) {
const durationMs = Math.round(performance.now() - startMs);
db.update(cronRuns)
.set({
status: "error",
finishedAt: new Date(),
durationMs,
errorMessage: err instanceof Error ? err.message : String(err),
})
.where(eq(cronRuns.id, run.id))
.run();
failCronRun(run.id, durationMs, err);
log.error(`Job ${name} failed:`, err);
}
}),
@@ -108,71 +95,6 @@ export async function triggerJob(name: string): Promise<boolean> {
return true;
}
function getLibraryTitleIds(): string[] {
const rows = db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.groupBy(userTitleStatus.titleId)
.all();
return rows.map((r) => r.titleId);
}
function getThumbhashBackfillTitleIds(): string[] {
const titleIds = new Set(getLibraryTitleIds());
const addIds = (ids: string[]) => {
for (const id of ids) titleIds.add(id);
};
addIds(
db
.select({ id: titles.id })
.from(titles)
.where(
or(
and(isNotNull(titles.posterPath), sql`${titles.posterThumbHash} IS NULL`),
and(isNotNull(titles.backdropPath), sql`${titles.backdropThumbHash} IS NULL`),
),
)
.all()
.map((row) => row.id),
);
addIds(
db
.select({ titleId: seasons.titleId })
.from(seasons)
.where(and(isNotNull(seasons.posterPath), sql`${seasons.posterThumbHash} IS NULL`))
.groupBy(seasons.titleId)
.all()
.map((row) => row.titleId),
);
addIds(
db
.select({ titleId: seasons.titleId })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(and(isNotNull(episodes.stillPath), sql`${episodes.stillThumbHash} IS NULL`))
.groupBy(seasons.titleId)
.all()
.map((row) => row.titleId),
);
addIds(
db
.select({ titleId: titleCast.titleId })
.from(titleCast)
.innerJoin(persons, eq(titleCast.personId, persons.id))
.where(and(isNotNull(persons.profilePath), sql`${persons.profileThumbHash} IS NULL`))
.groupBy(titleCast.titleId)
.all()
.map((row) => row.titleId),
);
return [...titleIds];
}
// Refresh titles where lastFetchedAt is stale
async function nightlyRefreshLibrary() {
const libraryIds = getLibraryTitleIds();
@@ -181,11 +103,7 @@ async function nightlyRefreshLibrary() {
const nonLibraryStale = new Date(Date.now() - 30 * DAY);
// Library titles: 7 days
const staleLibrary = db
.select({ id: titles.id })
.from(titles)
.where(and(inArray(titles.id, libraryIds), lt(titles.lastFetchedAt, libraryStale)))
.all();
const staleLibrary = getStaleLibraryTitles(libraryIds, libraryStale);
for (const { id } of staleLibrary) {
await refreshTitle(id);
@@ -193,12 +111,7 @@ async function nightlyRefreshLibrary() {
}
// Non-library titles: 30 days
const nonLibrary = db
.select()
.from(titles)
.where(and(isNotNull(titles.lastFetchedAt), lt(titles.lastFetchedAt, nonLibraryStale)))
.limit(50)
.all();
const nonLibrary = getStaleNonLibraryTitlesForRefresh(nonLibraryStale, 50);
for (const t of nonLibrary) {
if (!libraryIds.includes(t.id)) {
@@ -214,33 +127,10 @@ async function refreshAvailabilityJob() {
log.debug(`Checking availability for ${libraryIds.length} library titles`);
const stale = new Date(Date.now() - DAY);
const titlesWithOffers = new Set(
db
.select({ titleId: availabilityOffers.titleId })
.from(availabilityOffers)
.where(inArray(availabilityOffers.titleId, libraryIds))
.groupBy(availabilityOffers.titleId)
.all()
.map((r) => r.titleId),
);
const titlesWithStaleOffers = new Set(
db
.select({ titleId: availabilityOffers.titleId })
.from(availabilityOffers)
.where(
and(
inArray(availabilityOffers.titleId, libraryIds),
lt(availabilityOffers.lastFetchedAt, stale),
),
)
.groupBy(availabilityOffers.titleId)
.all()
.map((r) => r.titleId),
);
const { withOffers, withStaleOffers } = getStaleAvailabilityTitles(libraryIds, stale);
for (const titleId of libraryIds) {
if (titlesWithStaleOffers.has(titleId) || !titlesWithOffers.has(titleId)) {
if (withStaleOffers.has(titleId) || !withOffers.has(titleId)) {
await refreshAvailability(titleId);
await Bun.sleep(RATE_LIMIT_MS);
}
@@ -258,35 +148,14 @@ async function refreshRecommendationsJob() {
}
async function refreshTvChildrenJob() {
const returningStatuses = ["Returning Series", "In Production"];
const stale = new Date(Date.now() - 7 * DAY);
const tvShows = db
.select()
.from(titles)
.where(
and(
eq(titles.type, "tv"),
isNotNull(titles.lastFetchedAt),
or(...returningStatuses.map((s) => eq(titles.status, s))),
),
)
.all();
const tvShows = getReturningTvShows();
log.debug(`Checking ${tvShows.length} returning TV shows for stale episodes`);
const tvIds = tvShows.map((s) => s.id);
const titlesWithStaleSeasons = new Set(
tvIds.length > 0
? db
.select({ titleId: seasons.titleId })
.from(seasons)
.where(and(inArray(seasons.titleId, tvIds), lt(seasons.lastFetchedAt, stale)))
.groupBy(seasons.titleId)
.all()
.map((r) => r.titleId)
: [],
);
const titlesWithStaleSeasons = getTitleIdsWithStaleSeasons(tvIds, stale);
for (const show of tvShows) {
if (titlesWithStaleSeasons.has(show.id)) {
@@ -304,7 +173,7 @@ async function cacheImagesJob() {
for (const titleId of titleIds) {
try {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
const title = getTitleByIdForCron(titleId);
if (!title) continue;
// Phase 1: warm the image cache so thumbhash generation can read from disk
@@ -347,12 +216,7 @@ async function refreshCreditsJob() {
const stale = new Date(Date.now() - 30 * DAY);
for (const titleId of libraryIds) {
const castEntry = db
.select()
.from(titleCast)
.where(eq(titleCast.titleId, titleId))
.limit(1)
.get();
const castEntry = getCastEntryForTitle(titleId);
const needsRefresh = !castEntry || (castEntry.lastFetchedAt && castEntry.lastFetchedAt < stale);
+20 -40
View File
@@ -4,15 +4,15 @@ import { AppErrorCode } from "@sofa/api/errors";
import type { ParseResult } from "@sofa/core/imports";
import {
countUnresolved,
getActiveImportJobForUser,
insertImportJob,
parseLetterboxdExport,
parseSimklPayload,
parseTraktPayload,
processImportJob,
readImportJob,
updateImportJobProgress,
} from "@sofa/core/imports";
import { db } from "@sofa/db/client";
import { and, eq, inArray } from "@sofa/db/helpers";
import { importJobs } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { os } from "../context";
@@ -100,34 +100,18 @@ export const createJob = os.imports.createJob.use(authed).handler(async ({ input
// Prevent concurrent imports per user.
// Auto-cancel stale *pending* jobs (server crashed before worker started).
// Running jobs are never auto-cancelled — there's no heartbeat to
// distinguish active work from a dead worker, and killing a healthy
// long-running import is worse than making the user manually cancel.
const PENDING_STALE_MS = 5 * 60 * 1000; // 5 minutes
const now = Date.now();
const existing = db
.select()
.from(importJobs)
.where(
and(
eq(importJobs.userId, context.user.id),
inArray(importJobs.status, ["pending", "running"]),
),
)
.get();
const existing = getActiveImportJobForUser(context.user.id);
if (existing) {
const isPending = existing.status === "pending";
const isStale = isPending && now - existing.createdAt.getTime() > PENDING_STALE_MS;
if (isStale) {
// Mark as cancelled so the worker loop also stops if it starts late
db.update(importJobs)
.set({
status: "cancelled",
finishedAt: new Date(),
currentMessage: "Import timed out (stale job auto-cancelled)",
})
.where(eq(importJobs.id, existing.id))
.run();
updateImportJobProgress(existing.id, {
status: "cancelled",
finishedAt: new Date(),
currentMessage: "Import timed out (stale job auto-cancelled)",
});
log.warn(`Auto-cancelled stale import job ${existing.id}`);
} else {
throw new ORPCError("CONFLICT", {
@@ -137,20 +121,16 @@ export const createJob = os.imports.createJob.use(authed).handler(async ({ input
}
}
const job = db
.insert(importJobs)
.values({
userId: context.user.id,
source: data.source,
status: "pending",
payload: JSON.stringify(data),
importWatches: options.importWatches,
importWatchlist: options.importWatchlist,
importRatings: options.importRatings,
createdAt: new Date(),
})
.returning()
.get();
const job = insertImportJob({
userId: context.user.id,
source: data.source,
status: "pending",
payload: JSON.stringify(data),
importWatches: options.importWatches,
importWatchlist: options.importWatchlist,
importRatings: options.importRatings,
createdAt: new Date(),
});
// Fire-and-forget processing
processImportJob(job.id).catch((err) => {
@@ -172,7 +152,7 @@ export const cancelJob = os.imports.cancelJob.use(authed).handler(({ input, cont
data: { code: AppErrorCode.IMPORT_CANNOT_CANCEL },
});
}
db.update(importJobs).set({ status: "cancelled" }).where(eq(importJobs.id, input.id)).run();
updateImportJobProgress(input.id, { status: "cancelled" });
return readImportJob(input.id);
});
+11 -108
View File
@@ -1,132 +1,35 @@
import { ORPCError } from "@orpc/server";
import { AppErrorCode } from "@sofa/api/errors";
import { db } from "@sofa/db/client";
import { and, desc, eq } from "@sofa/db/helpers";
import { integrationEvents, integrations } from "@sofa/db/schema";
import {
createOrUpdateIntegration,
deleteIntegration as coreDeleteIntegration,
listUserIntegrations,
regenerateToken as coreRegenerateToken,
serializeIntegration,
} from "@sofa/core/integrations";
import { os } from "../context";
import { authed } from "../middleware";
const LIST_PROVIDERS = new Set(["sonarr", "radarr"]);
function integrationTypeFor(provider: string): "webhook" | "list" {
return LIST_PROVIDERS.has(provider) ? "list" : "webhook";
}
function generateToken() {
return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex");
}
function serializeIntegration(row: {
id: string;
provider: string;
type: "webhook" | "list";
token: string;
enabled: boolean;
lastEventAt: Date | null;
createdAt: Date;
}) {
return {
...row,
lastEventAt: row.lastEventAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
export const list = os.integrations.list.use(authed).handler(({ context }) => {
const userIntegrations = db
.select()
.from(integrations)
.where(eq(integrations.userId, context.user.id))
.all();
const eventsByIntegration = new Map<string, (typeof integrationEvents.$inferSelect)[]>();
for (const integration of userIntegrations) {
const events = db
.select()
.from(integrationEvents)
.where(eq(integrationEvents.integrationId, integration.id))
.orderBy(desc(integrationEvents.receivedAt))
.limit(10)
.all();
eventsByIntegration.set(integration.id, events);
}
const result = userIntegrations.map((integration) => {
const events = eventsByIntegration.get(integration.id) ?? [];
return Object.assign(serializeIntegration(integration), {
recentEvents: events.map((e) => ({
id: e.id,
eventType: e.eventType,
mediaType: e.mediaType,
mediaTitle: e.mediaTitle,
status: e.status,
receivedAt: e.receivedAt.toISOString(),
})),
});
});
return { integrations: result };
return listUserIntegrations(context.user.id);
});
export const create = os.integrations.create.use(authed).handler(({ input, context }) => {
const existing = db
.select()
.from(integrations)
.where(and(eq(integrations.userId, context.user.id), eq(integrations.provider, input.provider)))
.get();
if (existing) {
if (input.enabled !== undefined) {
const row = db
.update(integrations)
.set({ enabled: input.enabled })
.where(eq(integrations.id, existing.id))
.returning()
.get();
return serializeIntegration(row);
}
return serializeIntegration(existing);
}
const row = db
.insert(integrations)
.values({
userId: context.user.id,
provider: input.provider,
type: integrationTypeFor(input.provider),
token: generateToken(),
enabled: input.enabled ?? true,
createdAt: new Date(),
})
.returning()
.get();
return serializeIntegration(row);
return createOrUpdateIntegration(context.user.id, input.provider, input.enabled);
});
export const deleteIntegration = os.integrations.delete
.use(authed)
.handler(({ input, context }) => {
db.delete(integrations)
.where(
and(eq(integrations.userId, context.user.id), eq(integrations.provider, input.provider)),
)
.run();
coreDeleteIntegration(context.user.id, input.provider);
});
export const regenerateToken = os.integrations.regenerateToken
.use(authed)
.handler(({ input, context }) => {
const row = db
.update(integrations)
.set({ token: generateToken() })
.where(
and(eq(integrations.userId, context.user.id), eq(integrations.provider, input.provider)),
)
.returning()
.get();
const row = coreRegenerateToken(context.user.id, input.provider);
if (!row) {
throw new ORPCError("NOT_FOUND", {
+2 -9
View File
@@ -1,17 +1,10 @@
import { logEpisodeWatchBatch, unwatchSeason } from "@sofa/core/tracking";
import { db } from "@sofa/db/client";
import { eq } from "@sofa/db/helpers";
import { episodes } from "@sofa/db/schema";
import { unwatchSeason, watchSeason } from "@sofa/core/tracking";
import { os } from "../context";
import { authed } from "../middleware";
export const watch = os.seasons.watch.use(authed).handler(({ input, context }) => {
const seasonEps = db.select().from(episodes).where(eq(episodes.seasonId, input.id)).all();
logEpisodeWatchBatch(
context.user.id,
seasonEps.map((ep) => ep.id),
);
watchSeason(context.user.id, input.id);
});
export const unwatch = os.seasons.unwatch.use(authed).handler(({ input, context }) => {
+6 -23
View File
@@ -8,13 +8,11 @@ import {
getUserTitleInfo,
logMovieWatch,
markAllEpisodesWatched,
quickAddTitle,
rateTitleStars,
removeTitleStatus,
setTitleStatus,
} from "@sofa/core/tracking";
import { db } from "@sofa/db/client";
import { and, eq } from "@sofa/db/helpers";
import { titles, userTitleStatus } from "@sofa/db/schema";
import { os } from "../context";
import { authed } from "../middleware";
@@ -65,31 +63,16 @@ export const recommendations = os.titles.recommendations
});
export const quickAdd = os.titles.quickAdd.use(authed).handler(async ({ input, context }) => {
// Look up the title (it exists as a shell from browse/search import)
const title = db
.select({ id: titles.id, tmdbId: titles.tmdbId, type: titles.type })
.from(titles)
.where(eq(titles.id, input.id))
.get();
if (!title) {
const result = quickAddTitle(context.user.id, input.id);
if (!result) {
throw new ORPCError("NOT_FOUND", {
message: "Title not found",
data: { code: AppErrorCode.TITLE_NOT_FOUND },
});
}
// Trigger full TMDB import if still a shell
getOrFetchTitleByTmdbId(title.tmdbId, title.type as "movie" | "tv").catch(() => {});
// Trigger full TMDB import if still a shell (fire-and-forget)
getOrFetchTitleByTmdbId(result.tmdbId, result.type as "movie" | "tv").catch(() => {});
const existing = db
.select()
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, context.user.id), eq(userTitleStatus.titleId, title.id)))
.get();
if (!existing) {
setTitleStatus(context.user.id, title.id, "watchlist");
}
return { id: title.id, alreadyAdded: !!existing };
return { id: result.id, alreadyAdded: result.alreadyAdded };
});
+2 -4
View File
@@ -1,5 +1,6 @@
import { Hono } from "hono";
import { findIntegrationByToken } from "@sofa/core/integrations";
import type { WebhookEvent } from "@sofa/core/webhooks";
import {
parseEmbyPayload,
@@ -7,9 +8,6 @@ import {
parsePlexPayload,
processWebhook,
} from "@sofa/core/webhooks";
import { db } from "@sofa/db/client";
import { eq } from "@sofa/db/helpers";
import { integrations } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
const log = createLogger("webhooks");
@@ -20,7 +18,7 @@ app.post("/:token", async (c) => {
const token = c.req.param("token");
// Look up connection by token — this IS the auth
const connection = db.select().from(integrations).where(eq(integrations.token, token)).get();
const connection = findIntegrationByToken(token);
if (!connection || !connection.enabled) {
// Always return 200 to avoid retry storms from media servers
+2
View File
@@ -9,10 +9,12 @@
"./cache": "./src/cache.ts",
"./colors": "./src/colors.ts",
"./credits": "./src/credits.ts",
"./cron": "./src/cron.ts",
"./discovery": "./src/discovery.ts",
"./image-cache": "./src/image-cache.ts",
"./imports": "./src/imports/index.ts",
"./imports/parsers": "./src/imports/parsers.ts",
"./integrations": "./src/integrations.ts",
"./lists": "./src/lists.ts",
"./metadata": "./src/metadata.ts",
"./person": "./src/person.ts",
+9 -14
View File
@@ -1,13 +1,16 @@
import { db } from "@sofa/db/client";
import { and, eq } from "@sofa/db/helpers";
import { availabilityOffers, titles } from "@sofa/db/schema";
import {
getAvailabilityOffers,
replaceAvailabilityTransaction,
} from "@sofa/db/queries/availability";
import { getTitleById } from "@sofa/db/queries/title";
import type { availabilityOffers } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { getWatchProviders } from "@sofa/tmdb/client";
const log = createLogger("availability");
export async function refreshAvailability(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
const title = getTitleById(titleId);
if (!title) return;
const data = await getWatchProviders(title.tmdbId, title.type);
@@ -39,20 +42,12 @@ export async function refreshAvailability(titleId: string) {
}
}
db.transaction((tx) => {
tx.delete(availabilityOffers)
.where(and(eq(availabilityOffers.titleId, titleId), eq(availabilityOffers.region, "US")))
.run();
if (allOfferRows.length > 0) {
tx.insert(availabilityOffers).values(allOfferRows).onConflictDoNothing().run();
}
});
replaceAvailabilityTransaction(titleId, "US", allOfferRows);
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) {
return db.select().from(availabilityOffers).where(eq(availabilityOffers.titleId, titleId)).all();
return getAvailabilityOffers(titleId);
}
+3 -4
View File
@@ -4,9 +4,9 @@ import { mkdir, readdir } from "node:fs/promises";
import path from "node:path";
import { BACKUP_DIR, DATABASE_URL } from "@sofa/config";
import { closeDatabase, db } from "@sofa/db/client";
import { sql } from "@sofa/db/helpers";
import { closeDatabase } from "@sofa/db/client";
import { runMigrations } from "@sofa/db/migrate";
import { vacuumInto } from "@sofa/db/utils";
import { createLogger } from "@sofa/logger";
function formatTimestamp(date: Date): string {
@@ -138,8 +138,7 @@ async function createBackupInternal(prefix: BackupPrefix): Promise<BackupInfo> {
const filename = `${prefix}-${timestamp}.db`;
const dest = path.join(BACKUP_DIR, filename);
// VACUUM INTO atomically creates a clean, self-contained copy (safe for WAL mode)
db.run(sql.raw(`VACUUM INTO '${dest.replace(/'/g, "''")}'`));
vacuumInto(dest);
const s = await Bun.file(dest).stat();
log.info(`Created backup: ${filename} (${s.size} bytes)`);
+11 -69
View File
@@ -2,15 +2,11 @@ import { readdir, stat, unlink } from "node:fs/promises";
import path from "node:path";
import { CACHE_DIR } from "@sofa/config";
import { db } from "@sofa/db/client";
import { inArray, isNull, notInArray } from "@sofa/db/helpers";
import { persons, titleCast, titles, userTitleStatus } from "@sofa/db/schema";
import { purgeShellTitlesTransaction } from "@sofa/db/queries/cache";
import { createLogger } from "@sofa/logger";
const log = createLogger("purge");
const BATCH_SIZE = 500;
/**
* Delete un-enriched "shell" titles that aren't in any user's library,
* then clean up orphaned person records.
@@ -19,73 +15,19 @@ export function purgeMetadataCache(): {
deletedTitles: number;
deletedPersons: number;
} {
return db.transaction(() => {
// Find shell titles (never fully fetched) not in any user's library
const shellTitles = db
.select({ id: titles.id })
.from(titles)
.where(isNull(titles.lastFetchedAt))
.all();
const result = purgeShellTitlesTransaction();
if (shellTitles.length === 0) {
log.info("No un-enriched titles to purge");
return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons() };
}
// Filter out titles that are in any user's library
const libraryTitleIds = new Set(
db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.all()
.map((r) => r.titleId),
);
const toDelete = shellTitles.map((t) => t.id).filter((id) => !libraryTitleIds.has(id));
if (toDelete.length === 0) {
log.info("All un-enriched titles are in user libraries, skipping");
return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons() };
}
// Delete in batches to respect SQLite variable limits
for (let i = 0; i < toDelete.length; i += BATCH_SIZE) {
const batch = toDelete.slice(i, i + BATCH_SIZE);
db.delete(titles).where(inArray(titles.id, batch)).run();
}
const deletedTitles = toDelete.length;
log.info(`Purged ${deletedTitles} un-enriched titles`);
const deletedPersons = purgeOrphanedPersons();
return { deletedTitles, deletedPersons };
});
}
/**
* Delete person records that have no remaining titleCast references.
*/
function purgeOrphanedPersons(): number {
const orphanedPersons = db
.select({ id: persons.id })
.from(persons)
.where(
notInArray(persons.id, db.selectDistinct({ personId: titleCast.personId }).from(titleCast)),
)
.all();
if (orphanedPersons.length === 0) return 0;
const ids = orphanedPersons.map((p) => p.id);
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
const batch = ids.slice(i, i + BATCH_SIZE);
db.delete(persons).where(inArray(persons.id, batch)).run();
if (result.deletedTitles > 0) {
log.info(`Purged ${result.deletedTitles} un-enriched titles`);
} else {
log.info("No un-enriched titles to purge");
}
const deletedPersons = ids.length;
log.info(`Purged ${deletedPersons} orphaned persons`);
return deletedPersons;
if (result.deletedPersons > 0) {
log.info(`Purged ${result.deletedPersons} orphaned persons`);
}
return result;
}
/** Image cache subdirectories to scan */
+3 -8
View File
@@ -1,8 +1,6 @@
import { Vibrant } from "node-vibrant/node";
import { db } from "@sofa/db/client";
import { eq } from "@sofa/db/helpers";
import { titles } from "@sofa/db/schema";
import { updateTitleColorPalette } from "@sofa/db/queries/colors";
import { createLogger } from "@sofa/logger";
import { loadImageBuffer } from "./image-cache";
@@ -24,7 +22,7 @@ export async function extractAndStoreColors(
sourceBuffer?: Buffer | null,
): Promise<ColorPalette | null> {
if (!posterPath) {
db.update(titles).set({ colorPalette: null }).where(eq(titles.id, titleId)).run();
updateTitleColorPalette(titleId, null);
return null;
}
@@ -44,10 +42,7 @@ export async function extractAndStoreColors(
lightMuted: palette.LightMuted?.hex ?? null,
};
db.update(titles)
.set({ colorPalette: JSON.stringify(colors) })
.where(eq(titles.id, titleId))
.run();
updateTitleColorPalette(titleId, JSON.stringify(colors));
log.debug(`Extracted colors for title ${titleId}: colors=${JSON.stringify(colors)}`);
return colors;
+18 -127
View File
@@ -1,7 +1,14 @@
import type { CastMember } from "@sofa/api/schemas";
import { db } from "@sofa/db/client";
import { eq, inArray, sql } from "@sofa/db/helpers";
import { persons, titleCast, titles } from "@sofa/db/schema";
import {
batchUpsertPersonsTransaction,
batchUpsertTitleCast,
getCastForTitleJoined,
getExistingPersonsByTmdbIds,
getFallbackPersonsByTmdbIds,
getPersonsForTitleCast,
} from "@sofa/db/queries/credits";
import { getTitleById } from "@sofa/db/queries/title";
import type { titleCast } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { getMovieCredits, getTvAggregateCredits } from "@sofa/tmdb/client";
import { tmdbImageUrl } from "@sofa/tmdb/image";
@@ -38,68 +45,15 @@ function batchUpsertPersons(people: PersonData[]): Map<number, string> {
const tmdbIds = uniquePeople.map((p) => p.tmdbId);
// Batch prefetch existing persons (1 query)
const existing = db
.select({
id: persons.id,
tmdbId: persons.tmdbId,
name: persons.name,
profilePath: persons.profilePath,
profileThumbHash: persons.profileThumbHash,
popularity: persons.popularity,
})
.from(persons)
.where(inArray(persons.tmdbId, tmdbIds))
.all();
const existing = getExistingPersonsByTmdbIds(tmdbIds);
const existingMap = new Map(existing.map((p) => [p.tmdbId, p]));
const idMap = new Map<number, string>(existing.map((p) => [p.tmdbId, p.id]));
db.transaction((tx) => {
for (const p of uniquePeople) {
const existingPerson = existingMap.get(p.tmdbId);
if (existingPerson) {
const nextPopularity = p.popularity ?? null;
const pathChanged = existingPerson.profilePath !== p.profilePath;
const needsUpdate =
existingPerson.name !== p.name ||
pathChanged ||
existingPerson.popularity !== nextPopularity;
if (!needsUpdate) continue;
tx.update(persons)
.set({
name: p.name,
profilePath: p.profilePath,
popularity: nextPopularity,
profileThumbHash: pathChanged ? null : existingPerson.profileThumbHash,
})
.where(eq(persons.id, existingPerson.id))
.run();
continue;
}
const row = tx
.insert(persons)
.values({
tmdbId: p.tmdbId,
name: p.name,
profilePath: p.profilePath,
popularity: p.popularity ?? null,
})
.onConflictDoNothing()
.returning()
.get();
if (row) idMap.set(p.tmdbId, row.id);
}
});
const idMap = batchUpsertPersonsTransaction(uniquePeople, existingMap);
// One fallback query for any concurrent inserts that conflicted
const stillMissing = uniquePeople.filter((p) => !idMap.has(p.tmdbId)).map((p) => p.tmdbId);
if (stillMissing.length > 0) {
const fallbacks = db
.select({ id: persons.id, tmdbId: persons.tmdbId })
.from(persons)
.where(inArray(persons.tmdbId, stillMissing))
.all();
const fallbacks = getFallbackPersonsByTmdbIds(stillMissing);
for (const f of fallbacks) idMap.set(f.tmdbId, f.id);
}
@@ -115,16 +69,7 @@ export async function syncCastProfileThumbHashes(
await cacheProfilePhotos(titleId);
}
const personRows = db
.select({
id: persons.id,
profilePath: persons.profilePath,
profileThumbHash: persons.profileThumbHash,
})
.from(titleCast)
.innerJoin(persons, eq(titleCast.personId, persons.id))
.where(eq(titleCast.titleId, titleId))
.all();
const personRows = getPersonsForTitleCast(titleId);
const allowedIds = personIds ? new Set(personIds) : null;
const uniqueRows = new Map(personRows.map((p) => [p.id, p]));
@@ -137,7 +82,7 @@ export async function syncCastProfileThumbHashes(
}
export async function refreshCredits(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
const title = getTitleById(titleId);
if (!title) return;
log.debug(`Refreshing credits for "${title.title}" (${title.type})`);
@@ -211,25 +156,7 @@ export async function refreshCredits(titleId: string) {
});
crewOrder++;
}
if (allCastRows.length > 0) {
db.insert(titleCast)
.values(allCastRows)
.onConflictDoUpdate({
target: [
titleCast.titleId,
titleCast.personId,
titleCast.department,
titleCast.character,
],
set: {
job: sql`excluded.job`,
displayOrder: sql`excluded.displayOrder`,
episodeCount: sql`excluded.episodeCount`,
lastFetchedAt: sql`excluded.lastFetchedAt`,
},
})
.run();
}
batchUpsertTitleCast(allCastRows);
} else {
const credits = await getTvAggregateCredits(title.tmdbId);
const tvCast = credits.cast ?? [];
@@ -309,25 +236,7 @@ export async function refreshCredits(titleId: string) {
});
crewOrder++;
}
if (allCastRows.length > 0) {
db.insert(titleCast)
.values(allCastRows)
.onConflictDoUpdate({
target: [
titleCast.titleId,
titleCast.personId,
titleCast.department,
titleCast.character,
],
set: {
job: sql`excluded.job`,
displayOrder: sql`excluded.displayOrder`,
episodeCount: sql`excluded.episodeCount`,
lastFetchedAt: sql`excluded.lastFetchedAt`,
},
})
.run();
}
batchUpsertTitleCast(allCastRows);
}
log.debug(`Credits refreshed for "${title.title}"`);
@@ -341,25 +250,7 @@ export async function refreshCredits(titleId: string) {
}
export function getCastForTitle(titleId: string): CastMember[] {
const rows = db
.select({
id: titleCast.id,
personId: titleCast.personId,
name: persons.name,
character: titleCast.character,
department: titleCast.department,
job: titleCast.job,
displayOrder: titleCast.displayOrder,
episodeCount: titleCast.episodeCount,
profilePath: persons.profilePath,
profileThumbHash: persons.profileThumbHash,
tmdbId: persons.tmdbId,
})
.from(titleCast)
.innerJoin(persons, eq(titleCast.personId, persons.id))
.where(eq(titleCast.titleId, titleId))
.orderBy(titleCast.displayOrder)
.all();
const rows = getCastForTitleJoined(titleId);
return rows.map((r) =>
Object.assign(r, { profilePath: tmdbImageUrl(r.profilePath, "profiles") }),
+67
View File
@@ -0,0 +1,67 @@
import {
getCastEntryForTitle,
getLibraryTitleIds as queryGetLibraryTitleIds,
getReturningTvShows,
getStaleTitles,
getStaleNonLibraryTitles,
getTitleByIdForCron,
getTitleIdsWithMissingEpisodeThumbhashes,
getTitleIdsWithMissingProfileThumbhashes,
getTitleIdsWithMissingSeasonThumbhashes,
getTitleIdsWithStaleSeasons,
getTitlesWithMissingThumbhashes,
getTitlesWithStaleOffers,
getTitlesWithStaleOffersFetchedBefore,
insertCronRunReturning,
updateCronRunError,
updateCronRunSuccess,
} from "@sofa/db/queries/cron";
export function startCronRun(jobName: string) {
return insertCronRunReturning(jobName);
}
export function completeCronRun(runId: string, durationMs: number): void {
updateCronRunSuccess(runId, durationMs);
}
export function failCronRun(runId: string, durationMs: number, error: unknown): void {
const errorMessage = error instanceof Error ? error.message : String(error);
updateCronRunError(runId, durationMs, errorMessage);
}
export function getLibraryTitleIds(): string[] {
return queryGetLibraryTitleIds();
}
export function getThumbhashBackfillTitleIds(): string[] {
const titleIds = new Set(getLibraryTitleIds());
for (const id of getTitlesWithMissingThumbhashes()) titleIds.add(id);
for (const id of getTitleIdsWithMissingSeasonThumbhashes()) titleIds.add(id);
for (const id of getTitleIdsWithMissingEpisodeThumbhashes()) titleIds.add(id);
for (const id of getTitleIdsWithMissingProfileThumbhashes()) titleIds.add(id);
return [...titleIds];
}
export function getStaleLibraryTitles(libraryIds: string[], staleDate: Date) {
return getStaleTitles(libraryIds, staleDate);
}
export function getStaleNonLibraryTitlesForRefresh(staleDate: Date, limit: number) {
return getStaleNonLibraryTitles(staleDate, limit);
}
export function getStaleAvailabilityTitles(libraryIds: string[], staleDate: Date) {
const withOffers = getTitlesWithStaleOffers(libraryIds);
const withStaleOffers = getTitlesWithStaleOffersFetchedBefore(libraryIds, staleDate);
return { withOffers, withStaleOffers };
}
export {
getCastEntryForTitle,
getReturningTvShows,
getTitleByIdForCron,
getTitleIdsWithStaleSeasons,
};
+44 -198
View File
@@ -1,16 +1,24 @@
import { db } from "@sofa/db/client";
import { and, desc, eq, inArray, sql } from "@sofa/db/helpers";
import {
availabilityOffers,
episodes,
seasons,
titleRecommendations,
titles,
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "@sofa/db/schema";
getAllTrackedTitleIds,
getCompletedTitleIds,
getEpisodesBySeasonIds,
getEpisodeWatchCountSince,
getEpisodeWatchesByEpisodeIds,
getEpisodeWatchHistoryBuckets,
getHighlyRatedTitleIds,
getInProgressTitleIds,
getLibraryFeed,
getMovieWatchCountSince,
getMovieWatchHistoryBuckets,
getNewAvailableFeed,
getRecommendationRows,
getRecommendationRowsForTitle,
getSeasonsByTitleIds,
getTitleByIdOrNull,
getTitlesByIds,
getTvTitlesByIds,
getUserStatusCounts,
} from "@sofa/db/queries/discovery";
import { tmdbImageUrl } from "@sofa/tmdb/image";
export type TimePeriod = "today" | "this_week" | "this_month" | "this_year";
@@ -44,13 +52,10 @@ export function getWatchCount(
period: TimePeriod,
): number {
const timestamp = periodStartTimestamp(period);
const watchTable = table === "movies" ? userMovieWatches : userEpisodeWatches;
const [row] = db
.select({ count: sql<number>`count(*)` })
.from(watchTable)
.where(and(eq(watchTable.userId, userId), sql`${watchTable.watchedAt} >= ${timestamp}`))
.all();
return row?.count ?? 0;
if (table === "movies") {
return getMovieWatchCountSince(userId, timestamp);
}
return getEpisodeWatchCountSince(userId, timestamp);
}
export interface HistoryBucket {
@@ -71,7 +76,6 @@ export function getWatchHistory(
period: TimePeriod,
): HistoryBucket[] {
const startTs = periodStartTimestamp(period);
const watchTable = table === "movies" ? userMovieWatches : userEpisodeWatches;
const now = new Date();
let fmt: string;
@@ -114,17 +118,10 @@ export function getWatchHistory(
break;
}
const rows = db
.select({
bucket: sql<string>`strftime(${fmt}, ${watchTable.watchedAt}, 'unixepoch', 'localtime')`.as(
"bucket",
),
count: sql<number>`count(*)`.as("cnt"),
})
.from(watchTable)
.where(and(eq(watchTable.userId, userId), sql`${watchTable.watchedAt} >= ${startTs}`))
.groupBy(sql`strftime(${fmt}, ${watchTable.watchedAt}, 'unixepoch', 'localtime')`)
.all();
const rows =
table === "movies"
? getMovieWatchHistoryBuckets(userId, startTs, fmt)
: getEpisodeWatchHistoryBuckets(userId, startTs, fmt);
const countMap = new Map(rows.map((r) => [r.bucket, r.count]));
return buckets.map((b) => ({ bucket: b, count: countMap.get(b) ?? 0 }));
@@ -141,14 +138,7 @@ export function getUserStats(userId: string): DashboardStats {
const moviesThisMonth = getWatchCount(userId, "movies", "this_month");
const episodesThisWeek = getWatchCount(userId, "episodes", "this_week");
const [statusCounts] = db
.select({
librarySize: sql<number>`count(*)`,
completed: sql<number>`sum(case when ${userTitleStatus.status} = 'completed' then 1 else 0 end)`,
})
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all();
const statusCounts = getUserStatusCounts(userId);
return {
moviesThisMonth,
@@ -183,25 +173,14 @@ export interface ContinueWatchingItem {
export function getContinueWatchingFeed(userId: string): ContinueWatchingItem[] {
// Get in-progress TV shows
const inProgress = db
.select({
titleId: userTitleStatus.titleId,
updatedAt: userTitleStatus.updatedAt,
})
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.status, "in_progress")))
.all();
const inProgress = getInProgressTitleIds(userId);
if (inProgress.length === 0) return [];
const titleIds = inProgress.map((r) => r.titleId);
// Batch fetch all TV titles (1 query)
const tvTitles = db
.select()
.from(titles)
.where(and(inArray(titles.id, titleIds), eq(titles.type, "tv")))
.all();
const tvTitles = getTvTitlesByIds(titleIds);
if (tvTitles.length === 0) return [];
@@ -209,41 +188,16 @@ export function getContinueWatchingFeed(userId: string): ContinueWatchingItem[]
const titleMap = new Map(tvTitles.map((t) => [t.id, t]));
// Batch fetch all seasons for these titles (1 query)
const allSeasons = db
.select()
.from(seasons)
.where(inArray(seasons.titleId, tvTitleIds))
.orderBy(seasons.titleId, seasons.seasonNumber)
.all();
const allSeasons = getSeasonsByTitleIds(tvTitleIds);
const seasonIds = allSeasons.map((s) => s.id);
// Batch fetch all episodes for these seasons (1 query)
const allEpisodes =
seasonIds.length > 0
? db
.select()
.from(episodes)
.where(inArray(episodes.seasonId, seasonIds))
.orderBy(episodes.seasonId, episodes.episodeNumber)
.all()
: [];
const allEpisodes = getEpisodesBySeasonIds(seasonIds);
// Batch fetch all watches for this user for these episodes (1 query)
const episodeIds = allEpisodes.map((ep) => ep.id);
const allWatches =
episodeIds.length > 0
? db
.select()
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
inArray(userEpisodeWatches.episodeId, episodeIds),
),
)
.all()
: [];
const allWatches = getEpisodeWatchesByEpisodeIds(userId, episodeIds);
// Build lookup maps
const watchedEpisodeIds = new Set(allWatches.map((w) => w.episodeId));
@@ -339,123 +293,24 @@ export function getContinueWatchingFeed(userId: string): ContinueWatchingItem[]
return items;
}
export function getNewAvailableFeed(userId: string, _days = 14) {
// Get titles the user has in any status that have availability offers
// and recent release/air dates
const results = db
.select({
titleId: titles.id,
title: titles.title,
type: titles.type,
tmdbId: titles.tmdbId,
posterPath: titles.posterPath,
posterThumbHash: titles.posterThumbHash,
releaseDate: titles.releaseDate,
firstAirDate: titles.firstAirDate,
voteAverage: titles.voteAverage,
popularity: titles.popularity,
userStatus: userTitleStatus.status,
})
.from(titles)
.innerJoin(
userTitleStatus,
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
)
.where(
sql`EXISTS (SELECT 1 FROM ${availabilityOffers} WHERE ${availabilityOffers.titleId} = ${titles.id})`,
)
.orderBy(desc(titles.popularity))
.limit(20)
.all();
export { getNewAvailableFeed } from "@sofa/db/queries/discovery";
return results;
}
export function getLibraryFeed(userId: string, page = 1, limit = 20) {
const offset = (page - 1) * limit;
const availabilityFilter = sql`EXISTS (SELECT 1 FROM ${availabilityOffers} WHERE ${availabilityOffers.titleId} = ${titles.id})`;
const joinCondition = and(
eq(userTitleStatus.titleId, titles.id),
eq(userTitleStatus.userId, userId),
);
const [{ count }] = db
.select({ count: sql<number>`count(*)` })
.from(titles)
.innerJoin(userTitleStatus, joinCondition)
.where(availabilityFilter)
.all();
const items = db
.select({
titleId: titles.id,
title: titles.title,
type: titles.type,
tmdbId: titles.tmdbId,
posterPath: titles.posterPath,
posterThumbHash: titles.posterThumbHash,
releaseDate: titles.releaseDate,
firstAirDate: titles.firstAirDate,
voteAverage: titles.voteAverage,
popularity: titles.popularity,
userStatus: userTitleStatus.status,
})
.from(titles)
.innerJoin(userTitleStatus, joinCondition)
.where(availabilityFilter)
.orderBy(desc(titles.popularity))
.limit(limit)
.offset(offset)
.all();
const totalResults = count ?? 0;
return {
items,
page,
totalPages: Math.max(1, Math.ceil(totalResults / limit)),
totalResults,
};
}
export { getLibraryFeed } from "@sofa/db/queries/discovery";
export function getRecommendationsFeed(userId: string) {
// Get recommendations from user's highly-rated or completed titles
const userCompletedOrRated = db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.status, "completed")))
.all()
.map((r) => r.titleId);
const userCompletedOrRated = getCompletedTitleIds(userId);
const ratedIds = db
.select({ titleId: userRatings.titleId })
.from(userRatings)
.where(and(eq(userRatings.userId, userId), sql`${userRatings.ratingStars} >= 4`))
.all()
.map((r) => r.titleId);
const ratedIds = getHighlyRatedTitleIds(userId);
const sourceIds = [...new Set([...userCompletedOrRated, ...ratedIds])];
if (sourceIds.length === 0) return [];
// Get all tracked title IDs to exclude
const trackedIds = new Set(
db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all()
.map((r) => r.titleId),
);
const trackedIds = new Set(getAllTrackedTitleIds(userId));
// Batch fetch all recommendations for all source IDs (1 query)
const allRecRows = db
.select({
recommendedTitleId: titleRecommendations.recommendedTitleId,
rank: titleRecommendations.rank,
})
.from(titleRecommendations)
.where(inArray(titleRecommendations.titleId, sourceIds))
.all();
const allRecRows = getRecommendationRows(sourceIds);
const recs: Map<string, { titleId: string; score: number }> = new Map();
@@ -479,26 +334,17 @@ export function getRecommendationsFeed(userId: string) {
// Batch fetch all recommended titles (1 query)
const recTitleIds = sorted.map((r) => r.titleId);
const recTitles = db.select().from(titles).where(inArray(titles.id, recTitleIds)).all();
const recTitles = getTitlesByIds(recTitleIds);
const recTitleMap = new Map(recTitles.map((t) => [t.id, t]));
return sorted.map((r) => recTitleMap.get(r.titleId)).filter(Boolean);
}
export function getRecommendationsForTitle(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
const title = getTitleByIdOrNull(titleId);
if (!title) return [];
const recs = db
.select({
recommendedTitleId: titleRecommendations.recommendedTitleId,
source: titleRecommendations.source,
rank: titleRecommendations.rank,
})
.from(titleRecommendations)
.where(eq(titleRecommendations.titleId, titleId))
.orderBy(titleRecommendations.rank)
.all();
const recs = getRecommendationRowsForTitle(titleId);
if (recs.length === 0) return [];
@@ -521,7 +367,7 @@ export function getRecommendationsForTitle(titleId: string) {
// Batch fetch all recommended titles (1 query)
const recTitleIds = uniqueRecs.map((r) => r.recommendedTitleId);
const recTitles = db.select().from(titles).where(inArray(titles.id, recTitleIds)).all();
const recTitles = getTitlesByIds(recTitleIds);
const recTitleMap = new Map(recTitles.map((t) => [t.id, t]));
return uniqueRecs
+12 -22
View File
@@ -2,9 +2,13 @@ import { mkdir, rename } from "node:fs/promises";
import path from "node:path";
import { CACHE_DIR, TMDB_IMAGE_BASE_URL } from "@sofa/config";
import { db } from "@sofa/db/client";
import { eq, inArray } from "@sofa/db/helpers";
import { availabilityOffers, episodes, persons, seasons, titleCast, titles } from "@sofa/db/schema";
import {
getAvailabilityLogosForTitle,
getCastProfilePathsForTitle,
getEpisodeStillsForTitle,
getSeasonPostersForTitle,
getTitleWithPaths,
} from "@sofa/db/queries/image-cache";
import { createLogger } from "@sofa/logger";
import { IMAGE_CATEGORY_SIZES, type ImageCategory, tmdbCdnImageUrl } from "@sofa/tmdb/image";
@@ -129,7 +133,7 @@ export async function loadImageBuffer(
}
export async function cacheImagesForTitle(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
const title = getTitleWithPaths(titleId);
if (!title) return;
// Collect all candidate images, then check cache in parallel
@@ -138,7 +142,7 @@ export async function cacheImagesForTitle(titleId: string) {
if (title.backdropPath) candidates.push({ imgPath: title.backdropPath, category: "backdrops" });
if (title.type === "tv") {
const allSeasons = db.select().from(seasons).where(eq(seasons.titleId, titleId)).all();
const allSeasons = getSeasonPostersForTitle(titleId);
for (const s of allSeasons) {
if (s.posterPath) candidates.push({ imgPath: s.posterPath, category: "posters" });
}
@@ -164,12 +168,7 @@ export async function cacheImagesForTitle(titleId: string) {
}
export async function cacheEpisodeStills(titleId: string) {
const allSeasons = db.select().from(seasons).where(eq(seasons.titleId, titleId)).all();
const seasonIds = allSeasons.map((s) => s.id);
if (seasonIds.length === 0) return;
const allEps = db.select().from(episodes).where(inArray(episodes.seasonId, seasonIds)).all();
const allEps = getEpisodeStillsForTitle(titleId);
const epsWithStills = allEps.filter(
(ep): ep is typeof ep & { stillPath: string } => ep.stillPath != null,
@@ -189,11 +188,7 @@ export async function cacheEpisodeStills(titleId: string) {
}
export async function cacheProviderLogos(titleId: string) {
const offers = db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, titleId))
.all();
const offers = getAvailabilityLogosForTitle(titleId);
// Deduplicate and parallel cache checks
const uniqueLogos = new Map<string, string>();
@@ -217,12 +212,7 @@ export async function cacheProviderLogos(titleId: string) {
}
export async function cacheProfilePhotos(titleId: string) {
const castRows = db
.select({ profilePath: persons.profilePath })
.from(titleCast)
.innerJoin(persons, eq(titleCast.personId, persons.id))
.where(eq(titleCast.titleId, titleId))
.all();
const castRows = getCastProfilePathsForTitle(titleId);
// Deduplicate and parallel cache checks
const uniqueProfiles = new Map<string, string>();
+5
View File
@@ -18,4 +18,9 @@ export {
processImportJob,
readImportJob,
} from "./processor";
export {
getActiveImportJobForUser,
insertImportJob,
updateImportJobProgress,
} from "@sofa/db/queries/imports";
export { type ExternalIds, resolveMovieTmdbId, resolveShowTmdbId } from "./resolve";
+60 -145
View File
@@ -1,15 +1,14 @@
import { type ImportJob, NormalizedImportSchema } from "@sofa/api/schemas";
import { db } from "@sofa/db/client";
import { and, eq } from "@sofa/db/helpers";
import {
episodes,
importJobs,
seasons,
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "@sofa/db/schema";
getImportJob,
getImportJobStatus,
hasEpisodeWatch,
hasMovieWatch,
hasRating,
hasTitleStatus,
updateImportJobProgress,
} from "@sofa/db/queries/imports";
import { findEpisodeBySeasonAndNumber, findSeasonByTitleAndNumber } from "@sofa/db/queries/title";
import { createLogger } from "@sofa/logger";
import { getOrFetchTitleByTmdbId } from "../metadata";
@@ -46,44 +45,6 @@ export interface ImportResult {
warnings: string[];
}
// ─── Deduplication ──────────────────────────────────────────────────
function hasExistingMovieWatch(userId: string, titleId: string): boolean {
const existing = db
.select({ id: userMovieWatches.id })
.from(userMovieWatches)
.where(and(eq(userMovieWatches.userId, userId), eq(userMovieWatches.titleId, titleId)))
.get();
return !!existing;
}
function hasExistingEpisodeWatch(userId: string, episodeId: string): boolean {
const existing = db
.select({ id: userEpisodeWatches.id })
.from(userEpisodeWatches)
.where(and(eq(userEpisodeWatches.userId, userId), eq(userEpisodeWatches.episodeId, episodeId)))
.get();
return !!existing;
}
function hasExistingWatchlistStatus(userId: string, titleId: string): boolean {
const existing = db
.select({ status: userTitleStatus.status })
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
.get();
return !!existing;
}
function hasExistingRating(userId: string, titleId: string): boolean {
const existing = db
.select({ ratingStars: userRatings.ratingStars })
.from(userRatings)
.where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)))
.get();
return !!existing;
}
// ─── Item Processors ────────────────────────────────────────────────
async function processMovie(
@@ -117,7 +78,7 @@ async function processMovie(
return;
}
if (hasExistingMovieWatch(userId, title.id)) {
if (hasMovieWatch(userId, title.id)) {
result.skipped++;
return;
}
@@ -164,11 +125,7 @@ async function processEpisode(
}
// Find the specific episode in our DB
const season = db
.select()
.from(seasons)
.where(and(eq(seasons.titleId, title.id), eq(seasons.seasonNumber, ep.seasonNumber)))
.get();
const season = findSeasonByTitleAndNumber(title.id, ep.seasonNumber);
if (!season) {
result.failed++;
@@ -176,11 +133,7 @@ async function processEpisode(
return;
}
const episode = db
.select()
.from(episodes)
.where(and(eq(episodes.seasonId, season.id), eq(episodes.episodeNumber, ep.episodeNumber)))
.get();
const episode = findEpisodeBySeasonAndNumber(season.id, ep.episodeNumber);
if (!episode) {
result.failed++;
@@ -188,7 +141,7 @@ async function processEpisode(
return;
}
if (hasExistingEpisodeWatch(userId, episode.id)) {
if (hasEpisodeWatch(userId, episode.id)) {
result.skipped++;
return;
}
@@ -235,7 +188,7 @@ async function processWatchlistItem(
return;
}
if (hasExistingWatchlistStatus(userId, title.id)) {
if (hasTitleStatus(userId, title.id)) {
result.skipped++;
return;
}
@@ -277,7 +230,7 @@ async function processRating(
return;
}
if (hasExistingRating(userId, title.id)) {
if (hasRating(userId, title.id)) {
result.skipped++;
return;
}
@@ -294,27 +247,7 @@ async function processRating(
// ─── Read Job Helper ─────────────────────────────────────────────────
export function readImportJob(jobId: string, userId?: string): ImportJob {
const row = db
.select({
id: importJobs.id,
userId: importJobs.userId,
source: importJobs.source,
status: importJobs.status,
totalItems: importJobs.totalItems,
processedItems: importJobs.processedItems,
importedCount: importJobs.importedCount,
skippedCount: importJobs.skippedCount,
failedCount: importJobs.failedCount,
currentMessage: importJobs.currentMessage,
errors: importJobs.errors,
warnings: importJobs.warnings,
createdAt: importJobs.createdAt,
startedAt: importJobs.startedAt,
finishedAt: importJobs.finishedAt,
})
.from(importJobs)
.where(eq(importJobs.id, jobId))
.get();
const row = getImportJob(jobId);
if (!row) {
throw new Error(`Import job ${jobId} not found`);
@@ -345,7 +278,7 @@ export function readImportJob(jobId: string, userId?: string): ImportJob {
// ─── Job Processor ───────────────────────────────────────────────────
export async function processImportJob(jobId: string): Promise<void> {
const row = db.select().from(importJobs).where(eq(importJobs.id, jobId)).get();
const row = getImportJob(jobId);
if (!row) {
throw new Error(`Import job ${jobId} not found`);
@@ -397,23 +330,21 @@ export async function processImportJob(jobId: string): Promise<void> {
if (total === 0) {
result.warnings.push("No items to import with the selected options.");
db.update(importJobs)
.set({
status: "success",
finishedAt: new Date(),
totalItems: 0,
warnings: JSON.stringify(result.warnings),
})
.where(eq(importJobs.id, jobId))
.run();
updateImportJobProgress(jobId, {
status: "success",
finishedAt: new Date(),
totalItems: 0,
warnings: JSON.stringify(result.warnings),
});
return;
}
// Set status to running + totalItems atomically
db.update(importJobs)
.set({ status: "running", startedAt: new Date(), totalItems: total })
.where(eq(importJobs.id, jobId))
.run();
updateImportJobProgress(jobId, {
status: "running",
startedAt: new Date(),
totalItems: total,
});
log.info(`Starting ${data.source} import job ${jobId} for user ${row.userId}: ${total} items`);
@@ -424,21 +355,14 @@ export async function processImportJob(jobId: string): Promise<void> {
for (let i = 0; i < items.length; i++) {
// Check for cancellation periodically
if (i % progressInterval === 0) {
const currentStatus = db
.select({ status: importJobs.status })
.from(importJobs)
.where(eq(importJobs.id, jobId))
.get();
const currentStatus = getImportJobStatus(jobId);
if (currentStatus?.status === "cancelled") {
db.update(importJobs)
.set({
finishedAt: new Date(),
errors: JSON.stringify(result.errors),
warnings: JSON.stringify(result.warnings),
currentMessage: "Import cancelled",
})
.where(eq(importJobs.id, jobId))
.run();
updateImportJobProgress(jobId, {
finishedAt: new Date(),
errors: JSON.stringify(result.errors),
warnings: JSON.stringify(result.warnings),
currentMessage: "Import cancelled",
});
log.info(`Import job ${jobId} cancelled by user`);
return;
}
@@ -487,34 +411,28 @@ export async function processImportJob(jobId: string): Promise<void> {
? (currentItem.showTitle ?? "Unknown")
: "Unknown";
db.update(importJobs)
.set({
processedItems: i + 1,
importedCount: result.imported,
skippedCount: result.skipped,
failedCount: result.failed,
currentMessage: label,
})
.where(eq(importJobs.id, jobId))
.run();
updateImportJobProgress(jobId, {
processedItems: i + 1,
importedCount: result.imported,
skippedCount: result.skipped,
failedCount: result.failed,
currentMessage: label,
});
}
}
// Success
db.update(importJobs)
.set({
status: "success",
finishedAt: new Date(),
processedItems: total,
importedCount: result.imported,
skippedCount: result.skipped,
failedCount: result.failed,
errors: JSON.stringify(result.errors),
warnings: JSON.stringify(result.warnings),
currentMessage: "Import complete",
})
.where(eq(importJobs.id, jobId))
.run();
updateImportJobProgress(jobId, {
status: "success",
finishedAt: new Date(),
processedItems: total,
importedCount: result.imported,
skippedCount: result.skipped,
failedCount: result.failed,
errors: JSON.stringify(result.errors),
warnings: JSON.stringify(result.warnings),
currentMessage: "Import complete",
});
log.info(
`Import job ${jobId} complete: ${result.imported} imported, ${result.skipped} skipped, ${result.failed} failed`,
@@ -522,16 +440,13 @@ export async function processImportJob(jobId: string): Promise<void> {
} catch (err) {
// Fatal error
result.errors.push(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
db.update(importJobs)
.set({
status: "error",
finishedAt: new Date(),
errors: JSON.stringify(result.errors),
warnings: JSON.stringify(result.warnings),
currentMessage: "Import failed",
})
.where(eq(importJobs.id, jobId))
.run();
updateImportJobProgress(jobId, {
status: "error",
finishedAt: new Date(),
errors: JSON.stringify(result.errors),
warnings: JSON.stringify(result.warnings),
currentMessage: "Import failed",
});
log.error(`Import job ${jobId} failed:`, err);
}
+99
View File
@@ -0,0 +1,99 @@
import {
deleteIntegrationByUserAndProvider,
getIntegrationByToken,
getIntegrationByUserAndProvider,
getRecentEventsForIntegration,
getUserIntegrations,
insertIntegration,
regenerateIntegrationToken,
updateIntegrationEnabled,
} from "@sofa/db/queries/integrations";
const LIST_PROVIDERS = new Set(["sonarr", "radarr"]);
function integrationTypeFor(provider: string): "webhook" | "list" {
return LIST_PROVIDERS.has(provider) ? "list" : "webhook";
}
function generateToken() {
return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex");
}
function serializeIntegration(row: {
id: string;
provider: string;
type: "webhook" | "list";
token: string;
enabled: boolean;
lastEventAt: Date | null;
createdAt: Date;
}) {
return {
...row,
lastEventAt: row.lastEventAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
export function listUserIntegrations(userId: string) {
const userIntegrations = getUserIntegrations(userId);
const eventsByIntegration = new Map<string, ReturnType<typeof getRecentEventsForIntegration>>();
for (const integration of userIntegrations) {
const events = getRecentEventsForIntegration(integration.id);
eventsByIntegration.set(integration.id, events);
}
const result = userIntegrations.map((integration) => {
const events = eventsByIntegration.get(integration.id) ?? [];
return Object.assign(serializeIntegration(integration), {
recentEvents: events.map((e) => ({
id: e.id,
eventType: e.eventType,
mediaType: e.mediaType,
mediaTitle: e.mediaTitle,
status: e.status,
receivedAt: e.receivedAt.toISOString(),
})),
});
});
return { integrations: result };
}
export function createOrUpdateIntegration(userId: string, provider: string, enabled?: boolean) {
const existing = getIntegrationByUserAndProvider(userId, provider);
if (existing) {
if (enabled !== undefined) {
const row = updateIntegrationEnabled(existing.id, enabled);
return serializeIntegration(row);
}
return serializeIntegration(existing);
}
const row = insertIntegration({
userId,
provider,
type: integrationTypeFor(provider),
token: generateToken(),
enabled: enabled ?? true,
createdAt: new Date(),
});
return serializeIntegration(row);
}
export function deleteIntegration(userId: string, provider: string): void {
deleteIntegrationByUserAndProvider(userId, provider);
}
export function regenerateToken(userId: string, provider: string) {
return regenerateIntegrationToken(userId, provider, generateToken());
}
export function findIntegrationByToken(token: string) {
return getIntegrationByToken(token);
}
export { serializeIntegration };
+17 -54
View File
@@ -1,8 +1,11 @@
import { z } from "zod";
import { db } from "@sofa/db/client";
import { and, eq, inArray } from "@sofa/db/helpers";
import { integrations, titles, userTitleStatus } from "@sofa/db/schema";
import {
batchUpdateTvdbIds,
getRadarrMovies,
getSonarrShows,
resolveListIntegration,
} from "@sofa/db/queries/lists";
import { createLogger } from "@sofa/logger";
import { getTvExternalIds } from "@sofa/tmdb/client";
@@ -12,20 +15,7 @@ const log = createLogger("lists");
export function resolveListToken(
token: string,
): { userId: string; provider: "sonarr" | "radarr" } | null {
const row = db
.select({
userId: integrations.userId,
provider: integrations.provider,
})
.from(integrations)
.where(
and(
eq(integrations.token, token),
eq(integrations.type, "list"),
eq(integrations.enabled, true),
),
)
.get();
const row = resolveListIntegration(token);
if (!row) return null;
return {
userId: row.userId,
@@ -48,18 +38,7 @@ export function getRadarrList(
userId: string,
statuses: Status[] = ["watchlist"],
): { Id: number }[] {
const rows = db
.select({ tmdbId: titles.tmdbId })
.from(userTitleStatus)
.innerJoin(titles, eq(userTitleStatus.titleId, titles.id))
.where(
and(
eq(userTitleStatus.userId, userId),
eq(titles.type, "movie"),
inArray(userTitleStatus.status, statuses),
),
)
.all();
const rows = getRadarrMovies(userId, statuses);
return rows.map((r) => ({ Id: r.tmdbId }));
}
@@ -69,23 +48,7 @@ export async function getSonarrList(
userId: string,
statuses: Status[] = ["watchlist"],
): Promise<{ TvdbId: number; Title: string }[]> {
const rows = db
.select({
id: titles.id,
tmdbId: titles.tmdbId,
tvdbId: titles.tvdbId,
title: titles.title,
})
.from(userTitleStatus)
.innerJoin(titles, eq(userTitleStatus.titleId, titles.id))
.where(
and(
eq(userTitleStatus.userId, userId),
eq(titles.type, "tv"),
inArray(userTitleStatus.status, statuses),
),
)
.all();
const rows = getSonarrShows(userId, statuses);
// Resolve missing TVDB IDs in parallel instead of sequentially
const needsResolution = rows.filter((r) => r.tvdbId == null);
@@ -101,15 +64,15 @@ export async function getSonarrList(
}
}),
);
// Batch update resolved IDs in a single transaction
db.transaction((tx) => {
for (const { row, tvdbId } of resolved) {
if (tvdbId != null) {
row.tvdbId = tvdbId;
tx.update(titles).set({ tvdbId }).where(eq(titles.id, row.id)).run();
}
// Batch update resolved IDs
const updates: { titleId: string; tvdbId: number }[] = [];
for (const { row, tvdbId } of resolved) {
if (tvdbId != null) {
row.tvdbId = tvdbId;
updates.push({ titleId: row.id, tvdbId });
}
});
}
batchUpdateTvdbIds(updates);
}
return rows
+151 -501
View File
@@ -5,19 +5,34 @@ import type {
ResolvedTitle,
Season,
} from "@sofa/api/schemas";
import { db } from "@sofa/db/client";
import { and, eq, inArray, isNotNull, sql } from "@sofa/db/helpers";
import {
availabilityOffers,
episodes,
genres,
seasons,
titleGenres,
titleRecommendations,
titles,
} from "@sofa/db/schema";
batchUpsertEpisodes,
ensureBrowseTitlesTransaction,
getAvailabilityOffersForTitle,
getEpisodesBySeasonIds,
getEpisodesNeedingStillHash,
getExistingSeasonInfo,
getOldEpisodeStills,
getSeasonEpisodesWithHashes,
getSeasonsForTitle,
getTitleByTmdbIdAndType,
getTitleGenres,
getTitlesNeedingPosterHash,
hasRecommendationsForTitle,
hasSeasonForTitle,
insertTitleReturning,
nullifyEpisodeThumbHash,
nullifySeasonThumbHash,
updateTitleFields,
updateTrailerKey,
upsertGenresTransaction,
upsertRecommendationsTransaction,
upsertSeasonReturning,
} from "@sofa/db/queries/metadata";
import { getTitleById } from "@sofa/db/queries/title";
import type { titles } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import type { TmdbGenre, TmdbMovieDetails, TmdbTvDetails, TmdbVideo } from "@sofa/tmdb/client";
import type { TmdbMovieDetails, TmdbTvDetails, TmdbVideo } from "@sofa/tmdb/client";
import {
getMovieDetails,
getRecommendations,
@@ -47,39 +62,6 @@ import {
const log = createLogger("metadata");
/**
* Insert a title row, or return the existing one if a concurrent insert won the race.
* Catches SQLITE_CONSTRAINT_UNIQUE and falls back to a SELECT.
*/
function upsertTitle(values: typeof titles.$inferInsert, tmdbId: number) {
try {
return db.insert(titles).values(values).returning().get();
} catch (err: unknown) {
if (err instanceof Error && "code" in err && err.code === "SQLITE_CONSTRAINT_UNIQUE") {
log.debug(`TMDB ${tmdbId} was inserted concurrently, returning existing`);
return db.select().from(titles).where(eq(titles.tmdbId, tmdbId)).get();
}
throw err;
}
}
function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) {
const validGenres = tmdbGenres.filter((g): g is TmdbGenre & { name: string } => !!g.name);
if (validGenres.length === 0) return;
db.transaction((tx) => {
for (const g of validGenres) {
tx.insert(genres)
.values({ id: g.id, name: g.name })
.onConflictDoUpdate({ target: genres.id, set: { name: g.name } })
.run();
}
tx.delete(titleGenres).where(eq(titleGenres.titleId, titleId)).run();
for (const g of validGenres) {
tx.insert(titleGenres).values({ titleId, genreId: g.id }).onConflictDoNothing().run();
}
});
}
export function updateTitleWithArtInvalidation(
title: Pick<
typeof titles.$inferSelect,
@@ -97,23 +79,20 @@ export function updateTitleWithArtInvalidation(
const posterPathChanged = nextPosterPath !== title.posterPath;
const backdropPathChanged = nextBackdropPath !== title.backdropPath;
db.update(titles)
.set({
...values,
...(posterPathChanged
? {
posterThumbHash: null,
colorPalette: null,
}
: {}),
...(backdropPathChanged
? {
backdropThumbHash: null,
}
: {}),
})
.where(eq(titles.id, title.id))
.run();
updateTitleFields(title.id, {
...values,
...(posterPathChanged
? {
posterThumbHash: null,
colorPalette: null,
}
: {}),
...(backdropPathChanged
? {
backdropThumbHash: null,
}
: {}),
});
}
/** @internal */
@@ -173,11 +152,7 @@ export function getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv"): I
async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
log.debug(`Importing ${type} TMDB ${tmdbId}`);
const existing = db
.select()
.from(titles)
.where(and(eq(titles.tmdbId, tmdbId), eq(titles.type, type)))
.get();
const existing = getTitleByTmdbIdAndType(tmdbId, type);
if (existing) {
// Shell title — upgrade to full import
if (!existing.lastFetchedAt) {
@@ -200,9 +175,9 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
runtimeMinutes: movie.runtime ?? null,
lastFetchedAt: new Date(),
});
upsertGenres(existing.id, movie.genres ?? []);
upsertGenresTransaction(existing.id, movie.genres ?? []);
fireAndForgetEnrichment(existing.id, movie.poster_path, movie.backdrop_path, "movie");
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
return getTitleById(existing.id);
}
// TV shell — fetch details + children
@@ -218,26 +193,20 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
originalLanguage: show.original_language ?? null,
lastFetchedAt: new Date(),
});
upsertGenres(existing.id, show.genres ?? []);
upsertGenresTransaction(existing.id, show.genres ?? []);
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
fireAndForgetEnrichment(existing.id, show.poster_path, show.backdrop_path, "tv");
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
return getTitleById(existing.id);
}
// For fully-fetched TV shows, check if seasons are missing (e.g. prior failure)
if (existing.type === "tv") {
const hasSeason = db
.select({ id: seasons.id })
.from(seasons)
.where(eq(seasons.titleId, existing.id))
.limit(1)
.get();
if (!hasSeason) {
if (!hasSeasonForTitle(existing.id)) {
const show = await getTvDetails(tmdbId);
upsertGenres(existing.id, show.genres ?? []);
upsertGenresTransaction(existing.id, show.genres ?? []);
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
fireAndForgetEnrichment(existing.id, show.poster_path, show.backdrop_path, "tv");
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
return getTitleById(existing.id);
}
}
@@ -248,60 +217,54 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
if (type === "movie") {
const movie = await getMovieDetails(tmdbId);
const row = upsertTitle(
{
tmdbId: movie.id,
type: "movie",
title: movie.title ?? "",
originalTitle: movie.original_title,
overview: movie.overview,
releaseDate: movie.release_date || null,
posterPath: movie.poster_path,
backdropPath: movie.backdrop_path,
popularity: movie.popularity,
voteAverage: movie.vote_average,
voteCount: movie.vote_count,
status: movie.status,
contentRating: extractMovieContentRating(movie),
imdbId: movie.imdb_id ?? null,
originalLanguage: movie.original_language ?? null,
runtimeMinutes: movie.runtime ?? null,
lastFetchedAt: now,
},
tmdbId,
);
const row = insertTitleReturning({
tmdbId: movie.id,
type: "movie",
title: movie.title ?? "",
originalTitle: movie.original_title,
overview: movie.overview,
releaseDate: movie.release_date || null,
posterPath: movie.poster_path,
backdropPath: movie.backdrop_path,
popularity: movie.popularity,
voteAverage: movie.vote_average,
voteCount: movie.vote_count,
status: movie.status,
contentRating: extractMovieContentRating(movie),
imdbId: movie.imdb_id ?? null,
originalLanguage: movie.original_language ?? null,
runtimeMinutes: movie.runtime ?? null,
lastFetchedAt: now,
});
if (!row) return undefined;
upsertGenres(row.id, movie.genres ?? []);
upsertGenresTransaction(row.id, movie.genres ?? []);
fireAndForgetEnrichment(row.id, movie.poster_path, movie.backdrop_path, "movie");
log.info(`Imported movie "${movie.title}" (TMDB ${tmdbId})`);
return row;
}
const show = await getTvDetails(tmdbId);
const row = upsertTitle(
{
tmdbId: show.id,
tvdbId: show.external_ids?.tvdb_id ?? null,
type: "tv",
title: show.name ?? "",
originalTitle: show.original_name,
overview: show.overview,
firstAirDate: show.first_air_date || null,
posterPath: show.poster_path,
backdropPath: show.backdrop_path,
popularity: show.popularity,
voteAverage: show.vote_average,
voteCount: show.vote_count,
status: show.status,
contentRating: extractTvContentRating(show),
imdbId: show.external_ids?.imdb_id ?? null,
originalLanguage: show.original_language ?? null,
lastFetchedAt: now,
},
tmdbId,
);
const row = insertTitleReturning({
tmdbId: show.id,
tvdbId: show.external_ids?.tvdb_id ?? null,
type: "tv",
title: show.name ?? "",
originalTitle: show.original_name,
overview: show.overview,
firstAirDate: show.first_air_date || null,
posterPath: show.poster_path,
backdropPath: show.backdrop_path,
popularity: show.popularity,
voteAverage: show.vote_average,
voteCount: show.vote_count,
status: show.status,
contentRating: extractTvContentRating(show),
imdbId: show.external_ids?.imdb_id ?? null,
originalLanguage: show.original_language ?? null,
lastFetchedAt: now,
});
if (!row) return undefined;
upsertGenres(row.id, show.genres ?? []);
upsertGenresTransaction(row.id, show.genres ?? []);
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
fireAndForgetEnrichment(row.id, show.poster_path, show.backdrop_path, "tv");
@@ -310,7 +273,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
}
export async function refreshTitle(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
const title = getTitleById(titleId);
if (!title) return null;
const now = new Date();
@@ -334,7 +297,7 @@ export async function refreshTitle(titleId: string) {
runtimeMinutes: movie.runtime ?? null,
lastFetchedAt: now,
});
upsertGenres(titleId, movie.genres ?? []);
upsertGenresTransaction(titleId, movie.genres ?? []);
} else {
const show = await getTvDetails(title.tmdbId);
updateTitleWithArtInvalidation(title, {
@@ -354,11 +317,11 @@ export async function refreshTitle(titleId: string) {
originalLanguage: show.original_language ?? null,
lastFetchedAt: now,
});
upsertGenres(titleId, show.genres ?? []);
upsertGenresTransaction(titleId, show.genres ?? []);
await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons);
}
const updated = db.select().from(titles).where(eq(titles.id, titleId)).get();
const updated = getTitleById(titleId);
if (updated) {
syncTitleArt(
updated.id,
@@ -391,109 +354,53 @@ export async function refreshTvChildren(titleId: string, tmdbId: number, numberO
const seasonData = result.value;
const existingSeason = db
.select({
id: seasons.id,
posterPath: seasons.posterPath,
})
.from(seasons)
.where(and(eq(seasons.titleId, titleId), eq(seasons.seasonNumber, sn)))
.get();
const existingSeason = getExistingSeasonInfo(titleId, sn);
const seasonRow = db
.insert(seasons)
.values({
titleId,
seasonNumber: seasonData.season_number,
name: seasonData.name,
overview: seasonData.overview,
posterPath: seasonData.poster_path,
airDate: seasonData.air_date,
lastFetchedAt: now,
})
.onConflictDoUpdate({
target: [seasons.titleId, seasons.seasonNumber],
set: {
name: seasonData.name,
overview: seasonData.overview,
posterPath: seasonData.poster_path,
airDate: seasonData.air_date,
lastFetchedAt: now,
},
})
.returning()
.get();
const seasonRow = upsertSeasonReturning({
titleId,
seasonNumber: seasonData.season_number,
name: seasonData.name ?? null,
overview: seasonData.overview ?? null,
posterPath: seasonData.poster_path ?? null,
airDate: seasonData.air_date ?? null,
lastFetchedAt: now,
});
// Snapshot existing episode still paths before upsert so we can detect changes
const oldEpStills = new Map(
db
.select({
episodeNumber: episodes.episodeNumber,
stillPath: episodes.stillPath,
})
.from(episodes)
.where(eq(episodes.seasonId, existingSeason?.id ?? seasonRow.id))
.all()
.map((e) => [e.episodeNumber, e.stillPath] as const),
);
const oldEpStills = getOldEpisodeStills(existingSeason?.id ?? seasonRow.id);
// Batch all episode upserts in a single transaction per season
const eps = seasonData.episodes ?? [];
if (eps.length > 0) {
db.transaction((tx) => {
for (const ep of eps) {
tx.insert(episodes)
.values({
seasonId: seasonRow.id,
episodeNumber: ep.episode_number,
name: ep.name,
overview: ep.overview,
stillPath: ep.still_path,
airDate: ep.air_date,
runtimeMinutes: ep.runtime,
})
.onConflictDoUpdate({
target: [episodes.seasonId, episodes.episodeNumber],
set: {
name: ep.name,
overview: ep.overview,
stillPath: ep.still_path,
airDate: ep.air_date,
runtimeMinutes: ep.runtime,
},
})
.run();
}
});
}
batchUpsertEpisodes(
seasonRow.id,
eps.map((ep) => ({
episode_number: ep.episode_number,
name: ep.name ?? null,
overview: ep.overview ?? null,
still_path: ep.still_path ?? null,
air_date: ep.air_date ?? null,
runtime: ep.runtime,
})),
);
// Clear stale hashes when image paths change during the upsert.
// Full hash (re)generation is handled by syncTitleArt() after
// cache warming, so we only need to null out stale values here.
if ((existingSeason?.posterPath ?? null) !== (seasonData.poster_path ?? null)) {
db.update(seasons).set({ posterThumbHash: null }).where(eq(seasons.id, seasonRow.id)).run();
nullifySeasonThumbHash(seasonRow.id);
}
const seasonEps = db
.select({
id: episodes.id,
episodeNumber: episodes.episodeNumber,
stillPath: episodes.stillPath,
stillThumbHash: episodes.stillThumbHash,
})
.from(episodes)
.where(eq(episodes.seasonId, seasonRow.id))
.all();
const seasonEps = getSeasonEpisodesWithHashes(seasonRow.id);
for (const ep of seasonEps) {
const oldStill = oldEpStills.get(ep.episodeNumber);
if (oldStill !== ep.stillPath && ep.stillThumbHash) {
db.update(episodes).set({ stillThumbHash: null }).where(eq(episodes.id, ep.id)).run();
nullifyEpisodeThumbHash(ep.id);
}
}
}
}
export async function refreshRecommendations(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
const title = getTitleById(titleId);
if (!title) return;
const now = new Date();
@@ -576,146 +483,23 @@ export async function refreshRecommendations(titleId: string) {
});
}
// Batch prefetch existing titles (1 query)
const tmdbIds = [...uniqueTitles.keys()];
const existingTitles = db
.select({
id: titles.id,
tmdbId: titles.tmdbId,
posterPath: titles.posterPath,
backdropPath: titles.backdropPath,
posterThumbHash: titles.posterThumbHash,
backdropThumbHash: titles.backdropThumbHash,
})
.from(titles)
.where(inArray(titles.tmdbId, tmdbIds))
.all();
const existingTitleMap = new Map(existingTitles.map((t) => [t.tmdbId, t]));
const titleIdMap = new Map<number, string>(existingTitles.map((t) => [t.tmdbId, t.id]));
// Insert missing titles + upsert recommendations in a single transaction
db.transaction((tx) => {
const insertedTmdbIds = new Set<number>();
for (const item of uniqueTitles.values()) {
const existingTitle = existingTitleMap.get(item.tmdbId);
if (existingTitle) {
const posterPathChanged = existingTitle.posterPath !== item.posterPath;
const backdropPathChanged = existingTitle.backdropPath !== item.backdropPath;
tx.update(titles)
.set({
type: item.type,
title: item.title,
originalTitle: item.originalTitle,
overview: item.overview,
releaseDate: item.releaseDate,
firstAirDate: item.firstAirDate,
posterPath: item.posterPath,
backdropPath: item.backdropPath,
popularity: item.popularity,
voteAverage: item.voteAverage,
voteCount: item.voteCount,
...(posterPathChanged
? {
posterThumbHash: null,
colorPalette: null,
}
: {}),
...(backdropPathChanged
? {
backdropThumbHash: null,
}
: {}),
})
.where(eq(titles.id, existingTitle.id))
.run();
continue;
}
insertedTmdbIds.add(item.tmdbId);
const row = tx
.insert(titles)
.values({
tmdbId: item.tmdbId,
type: item.type,
title: item.title,
originalTitle: item.originalTitle,
overview: item.overview,
releaseDate: item.releaseDate,
firstAirDate: item.firstAirDate,
posterPath: item.posterPath,
backdropPath: item.backdropPath,
popularity: item.popularity,
voteAverage: item.voteAverage,
voteCount: item.voteCount,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning()
.get();
if (row) titleIdMap.set(item.tmdbId, row.id);
}
// One fallback query for any that conflicted
const stillMissing = [...insertedTmdbIds].filter((id) => !titleIdMap.has(id));
if (stillMissing.length > 0) {
const fallbacks = tx
.select({ id: titles.id, tmdbId: titles.tmdbId })
.from(titles)
.where(inArray(titles.tmdbId, stillMissing))
.all();
for (const f of fallbacks) titleIdMap.set(f.tmdbId, f.id);
}
// Batch upsert all recommendation rows (N inserts → 1)
const recRows = allItems
.map((item) => {
const recTitleId = titleIdMap.get(item.result.id);
if (!recTitleId) return null;
return {
titleId,
recommendedTitleId: recTitleId,
source: item.source,
rank: item.rank,
lastFetchedAt: now,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
if (recRows.length > 0) {
tx.insert(titleRecommendations)
.values(recRows)
.onConflictDoUpdate({
target: [
titleRecommendations.titleId,
titleRecommendations.recommendedTitleId,
titleRecommendations.source,
],
set: {
rank: sql`excluded.rank`,
lastFetchedAt: sql`excluded.lastFetchedAt`,
},
})
.run();
}
});
const titleIdMap = upsertRecommendationsTransaction(
titleId,
uniqueTitles,
allItems.map((item) => ({
tmdbId: item.result.id,
source: item.source,
rank: item.rank,
})),
now,
);
// Fire-and-forget thumbhash generation for recommendation titles missing one
const recTitleIds = [
...new Set(allItems.map((i) => titleIdMap.get(i.result.id)).filter(Boolean)),
] as string[];
if (recTitleIds.length > 0) {
const needingHash = db
.select({ id: titles.id, posterPath: titles.posterPath })
.from(titles)
.where(
and(
inArray(titles.id, recTitleIds),
isNotNull(titles.posterPath),
sql`${titles.posterThumbHash} IS NULL`,
),
)
.all();
const needingHash = getTitlesNeedingPosterHash(recTitleIds);
for (const t of needingHash) {
generateTitlePosterThumbHash(t.id, t.posterPath).catch((err) =>
log.debug("Recommendation poster thumbhash failed:", err),
@@ -726,22 +510,12 @@ export async function refreshRecommendations(titleId: string) {
/** Fetch seasons from the DB, building the Season[] structure. */
function fetchSeasonsFromDb(titleId: string): Season[] {
const seasonRows = db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.orderBy(seasons.seasonNumber)
.all();
const seasonRows = getSeasonsForTitle(titleId);
if (seasonRows.length === 0) return [];
const seasonIds = seasonRows.map((s) => s.id);
const allEps = db
.select()
.from(episodes)
.where(inArray(episodes.seasonId, seasonIds))
.orderBy(episodes.seasonId, episodes.episodeNumber)
.all();
const allEps = getEpisodesBySeasonIds(seasonIds);
const epsBySeason = new Map<string, Episode[]>();
for (const ep of allEps) {
@@ -772,7 +546,7 @@ function fetchSeasonsFromDb(titleId: string): Season[] {
* Returns the hydrated seasons data.
*/
export async function ensureTvHydrated(titleId: string): Promise<Season[]> {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
const title = getTitleById(titleId);
if (!title || title.type !== "tv") return [];
const { tmdbId } = title;
@@ -791,7 +565,7 @@ export async function ensureTvHydrated(titleId: string): Promise<Season[]> {
originalLanguage: show.original_language ?? null,
lastFetchedAt: new Date(),
});
upsertGenres(titleId, show.genres ?? []);
upsertGenresTransaction(titleId, show.genres ?? []);
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
} catch (err) {
log.debug(`Failed to hydrate shell TV title ${titleId}:`, err);
@@ -841,14 +615,7 @@ async function ensureEnriched(
}
// Recommendations are loaded separately (Suspense), so check here
const hasRecommendations =
db
.select({ titleId: titleRecommendations.titleId })
.from(titleRecommendations)
.where(eq(titleRecommendations.titleId, titleId))
.limit(1)
.get() != null;
if (!hasRecommendations) {
if (!hasRecommendationsForTitle(titleId)) {
tasks.push(
refreshRecommendations(titleId).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
@@ -888,18 +655,13 @@ async function ensureEnriched(
}
function readAvailability(titleId: string, titleName: string): AvailabilityOffer[] {
return db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, titleId))
.all()
.map((a) => ({
providerId: a.providerId,
providerName: a.providerName,
logoPath: tmdbImageUrl(a.logoPath, "logos"),
offerType: a.offerType,
watchUrl: generateProviderUrl(a.providerId, titleName),
}));
return getAvailabilityOffersForTitle(titleId).map((a) => ({
providerId: a.providerId,
providerName: a.providerName,
logoPath: tmdbImageUrl(a.logoPath, "logos"),
offerType: a.offerType,
watchUrl: generateProviderUrl(a.providerId, titleName),
}));
}
export async function getOrFetchTitle(id: string): Promise<{
@@ -908,7 +670,7 @@ export async function getOrFetchTitle(id: string): Promise<{
availability: AvailabilityOffer[];
cast: CastMember[];
} | null> {
let title = db.select().from(titles).where(eq(titles.id, id)).get();
let title = getTitleById(id);
if (!title) return null;
// If this is a shell movie title, fetch full details now (movies are fast)
@@ -932,8 +694,8 @@ export async function getOrFetchTitle(id: string): Promise<{
runtimeMinutes: movie.runtime ?? null,
lastFetchedAt: new Date(),
});
upsertGenres(id, movie.genres ?? []);
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
upsertGenresTransaction(id, movie.genres ?? []);
title = getTitleById(id) ?? title;
} catch (err) {
log.debug(`Failed to hydrate shell movie title ${id}:`, err);
}
@@ -961,18 +723,13 @@ export async function getOrFetchTitle(id: string): Promise<{
// Re-read only what was missing
if (cast.length === 0) cast = getCastForTitle(id);
if (availability.length === 0) availability = readAvailability(title.id, title.title);
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
title = getTitleById(id) ?? title;
}
}
const palette = parseColorPalette(title.colorPalette);
const titleGenreRows = db
.select({ name: genres.name })
.from(titleGenres)
.innerJoin(genres, eq(titleGenres.genreId, genres.id))
.where(eq(titleGenres.titleId, id))
.all();
const titleGenreRows = getTitleGenres(id);
const resolvedTitle: ResolvedTitle = {
id: title.id,
@@ -1038,13 +795,13 @@ export function pickBestTrailer(videos: TmdbVideo[]): string | null {
}
export async function refreshTrailer(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
const title = getTitleById(titleId);
if (!title) return;
try {
const response = await getVideos(title.tmdbId, title.type);
const key = pickBestTrailer(response.results ?? []);
db.update(titles).set({ trailerVideoKey: key }).where(eq(titles.id, titleId)).run();
updateTrailerKey(titleId, key);
log.debug(`Trailer for "${title.title}": ${key ? `YouTube ${key}` : "none found"}`);
} catch (err) {
log.debug(`Failed to fetch trailer for title ${titleId}:`, err);
@@ -1052,7 +809,7 @@ export async function refreshTrailer(titleId: string) {
}
async function generateMissingTvChildThumbHashes(titleId: string) {
const titleSeasons = db.select().from(seasons).where(eq(seasons.titleId, titleId)).all();
const titleSeasons = getSeasonsForTitle(titleId);
const hashTasks: Promise<unknown>[] = [];
@@ -1064,20 +821,7 @@ async function generateMissingTvChildThumbHashes(titleId: string) {
const seasonIds = titleSeasons.map((s) => s.id);
if (seasonIds.length > 0) {
const epsNeedingHash = db
.select({
id: episodes.id,
stillPath: episodes.stillPath,
})
.from(episodes)
.where(
and(
inArray(episodes.seasonId, seasonIds),
isNotNull(episodes.stillPath),
sql`${episodes.stillThumbHash} IS NULL`,
),
)
.all();
const epsNeedingHash = getEpisodesNeedingStillHash(seasonIds);
for (const ep of epsNeedingHash) {
hashTasks.push(generateEpisodeThumbHash(ep.id, ep.stillPath));
}
@@ -1141,10 +885,6 @@ interface BrowseTitleInput {
voteCount?: number | null;
}
function browseTitleKey(tmdbId: number, type: string): string {
return `${tmdbId}-${type}`;
}
/**
* Ensure every browse/search result has a local title row.
* Inserts shell titles (`lastFetchedAt = null`) for new (tmdbId, type) pairs.
@@ -1153,95 +893,5 @@ function browseTitleKey(tmdbId: number, type: string): string {
export function ensureBrowseTitlesExist(
items: BrowseTitleInput[],
): Map<string, { id: string; posterThumbHash: string | null }> {
if (items.length === 0) return new Map();
// Deduplicate by (tmdbId, type)
const unique = new Map<string, BrowseTitleInput>();
for (const item of items) {
const key = browseTitleKey(item.tmdbId, item.type);
if (!unique.has(key)) unique.set(key, item);
}
const tmdbIds = [...new Set(items.map((i) => i.tmdbId))];
// Batch-fetch existing titles (1 query)
const existing = db
.select({
id: titles.id,
tmdbId: titles.tmdbId,
type: titles.type,
posterThumbHash: titles.posterThumbHash,
})
.from(titles)
.where(inArray(titles.tmdbId, tmdbIds))
.all();
const result = new Map<string, { id: string; posterThumbHash: string | null }>();
for (const row of existing) {
result.set(browseTitleKey(row.tmdbId, row.type), {
id: row.id,
posterThumbHash: row.posterThumbHash,
});
}
// Find items that need inserting
const missingKeys = [...unique.keys()].filter((key) => !result.has(key));
if (missingKeys.length === 0) return result;
// Insert missing in a single transaction
db.transaction((tx) => {
for (const key of missingKeys) {
const item = unique.get(key);
if (!item) continue;
const row = tx
.insert(titles)
.values({
tmdbId: item.tmdbId,
type: item.type,
title: item.title,
overview: item.overview ?? null,
releaseDate: item.releaseDate ?? null,
firstAirDate: item.firstAirDate ?? null,
posterPath: item.posterPath,
backdropPath: item.backdropPath ?? null,
popularity: item.popularity ?? null,
voteAverage: item.voteAverage ?? null,
voteCount: item.voteCount ?? null,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning({ id: titles.id, posterThumbHash: titles.posterThumbHash })
.get();
if (row) {
result.set(key, {
id: row.id,
posterThumbHash: row.posterThumbHash,
});
}
}
// Fallback for any that conflicted (concurrent insert)
const stillMissing = missingKeys.filter((key) => !result.has(key));
if (stillMissing.length > 0) {
const missingTmdbIds = stillMissing.map((key) => unique.get(key)?.tmdbId ?? 0);
const fallbacks = tx
.select({
id: titles.id,
tmdbId: titles.tmdbId,
type: titles.type,
posterThumbHash: titles.posterThumbHash,
})
.from(titles)
.where(inArray(titles.tmdbId, missingTmdbIds))
.all();
for (const f of fallbacks) {
result.set(browseTitleKey(f.tmdbId, f.type), {
id: f.id,
posterThumbHash: f.posterThumbHash,
});
}
}
});
return result;
return ensureBrowseTitlesTransaction(items);
}
+70 -178
View File
@@ -1,7 +1,16 @@
import type { PersonCredit, ResolvedPerson } from "@sofa/api/schemas";
import { db } from "@sofa/db/client";
import { eq, inArray } from "@sofa/db/helpers";
import { personFilmography, persons, titles } from "@sofa/db/schema";
import {
batchInsertShellTitlesTransaction,
ensureBrowsePersonsTransaction,
getPersonById,
getPersonByTmdbId,
getPersonFilmographyJoined,
insertPersonReturning,
replaceFilmographyTransaction,
updatePerson,
} from "@sofa/db/queries/person";
import { getExistingTitlesByTmdbIds } from "@sofa/db/queries/title";
import type { personFilmography } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { getPersonCombinedCredits, getPersonDetails } from "@sofa/tmdb/client";
import { tmdbImageUrl } from "@sofa/tmdb/image";
@@ -12,7 +21,10 @@ const log = createLogger("person");
const FILMOGRAPHY_STALE_MS = 30 * 24 * 60 * 60 * 1000;
async function ensureProfileThumbHash(
person: Pick<typeof persons.$inferSelect, "id" | "profilePath" | "profileThumbHash">,
person: Pick<
typeof import("@sofa/db/schema").persons.$inferSelect,
"id" | "profilePath" | "profileThumbHash"
>,
) {
if (!person.profilePath) {
if (person.profileThumbHash) {
@@ -29,28 +41,25 @@ async function ensureProfileThumbHash(
}
export async function getOrFetchPerson(personId: string): Promise<ResolvedPerson | null> {
const person = db.select().from(persons).where(eq(persons.id, personId)).get();
const person = getPersonById(personId);
if (!person) return null;
// Shell record — lazily hydrate from TMDB
if (!person.lastFetchedAt) {
try {
const details = await getPersonDetails(person.tmdbId);
db.update(persons)
.set({
name: details.name || person.name,
biography: details.biography || null,
birthday: details.birthday,
deathday: details.deathday,
placeOfBirth: details.place_of_birth,
profilePath: details.profile_path,
knownForDepartment: details.known_for_department,
popularity: details.popularity,
imdbId: details.imdb_id,
lastFetchedAt: new Date(),
})
.where(eq(persons.id, personId))
.run();
updatePerson(personId, {
name: details.name || person.name,
biography: details.biography || null,
birthday: details.birthday,
deathday: details.deathday,
placeOfBirth: details.place_of_birth,
profilePath: details.profile_path,
knownForDepartment: details.known_for_department,
popularity: details.popularity,
imdbId: details.imdb_id,
lastFetchedAt: new Date(),
});
const newProfilePath = details.profile_path ?? null;
const pathChanged = newProfilePath !== person.profilePath;
@@ -102,7 +111,7 @@ export async function getOrFetchPerson(personId: string): Promise<ResolvedPerson
}
export async function getOrFetchPersonByTmdbId(tmdbId: number): Promise<ResolvedPerson | null> {
const existing = db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get();
const existing = getPersonByTmdbId(tmdbId);
if (existing) {
return getOrFetchPerson(existing.id);
@@ -111,26 +120,21 @@ export async function getOrFetchPersonByTmdbId(tmdbId: number): Promise<Resolved
// Create from TMDB
try {
const details = await getPersonDetails(tmdbId);
const row = db
.insert(persons)
.values({
tmdbId,
name: details.name ?? "",
biography: details.biography || null,
birthday: details.birthday ?? null,
deathday: details.deathday ?? null,
placeOfBirth: details.place_of_birth ?? null,
profilePath: details.profile_path ?? null,
knownForDepartment: details.known_for_department ?? null,
popularity: details.popularity,
imdbId: details.imdb_id ?? null,
lastFetchedAt: new Date(),
})
.onConflictDoNothing()
.returning()
.get();
const row = insertPersonReturning({
tmdbId,
name: details.name ?? "",
biography: details.biography || null,
birthday: details.birthday ?? null,
deathday: details.deathday ?? null,
placeOfBirth: details.place_of_birth ?? null,
profilePath: details.profile_path ?? null,
knownForDepartment: details.known_for_department ?? null,
popularity: details.popularity,
imdbId: details.imdb_id ?? null,
lastFetchedAt: new Date(),
});
const person = row ?? db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get();
const person = row ?? getPersonByTmdbId(tmdbId);
if (!person) return null;
const profileThumbHash = await ensureProfileThumbHash(person);
@@ -155,26 +159,7 @@ export async function getOrFetchPersonByTmdbId(tmdbId: number): Promise<Resolved
}
export function getLocalFilmography(personId: string): PersonCredit[] {
const rows = db
.select({
titleId: titles.id,
tmdbId: titles.tmdbId,
type: titles.type,
title: titles.title,
posterPath: titles.posterPath,
posterThumbHash: titles.posterThumbHash,
releaseDate: titles.releaseDate,
firstAirDate: titles.firstAirDate,
voteAverage: titles.voteAverage,
character: personFilmography.character,
department: personFilmography.department,
job: personFilmography.job,
})
.from(personFilmography)
.innerJoin(titles, eq(personFilmography.titleId, titles.id))
.where(eq(personFilmography.personId, personId))
.orderBy(personFilmography.displayOrder)
.all();
const rows = getPersonFilmographyJoined(personId);
return rows.map((r) => ({
titleId: r.titleId,
@@ -192,7 +177,9 @@ export function getLocalFilmography(personId: string): PersonCredit[] {
}));
}
async function syncPersonFilmography(person: Pick<typeof persons.$inferSelect, "id" | "tmdbId">) {
async function syncPersonFilmography(
person: Pick<typeof import("@sofa/db/schema").persons.$inferSelect, "id" | "tmdbId">,
) {
const credits = await getPersonCombinedCredits(person.tmdbId);
// Schema types combined credits as movie-only; TV entries also carry
@@ -214,58 +201,32 @@ async function syncPersonFilmography(person: Pick<typeof persons.$inferSelect, "
const allEntries = [...validCast, ...validCrew];
const tmdbIds = [...new Set(allEntries.map((c) => c.id))];
const existingTitles =
tmdbIds.length > 0
? db
.select({ id: titles.id, tmdbId: titles.tmdbId })
.from(titles)
.where(inArray(titles.tmdbId, tmdbIds))
.all()
: [];
const existingTitles = getExistingTitlesByTmdbIds(tmdbIds);
const titleIdMap = new Map<number, string>(
existingTitles.map((title) => [title.tmdbId, title.id]),
);
const newEntries = allEntries.filter((entry) => !titleIdMap.has(entry.id));
if (newEntries.length > 0) {
const insertedTmdbIds = new Set<number>();
db.transaction((tx) => {
for (const entry of newEntries) {
if (insertedTmdbIds.has(entry.id)) continue;
insertedTmdbIds.add(entry.id);
const row = tx
.insert(titles)
.values({
tmdbId: entry.id,
type: entry.media_type as "movie" | "tv",
title: entry.title ?? entry.name ?? "Unknown",
overview: entry.overview,
releaseDate: entry.release_date,
firstAirDate: entry.first_air_date,
posterPath: entry.poster_path,
backdropPath: entry.backdrop_path,
popularity: entry.popularity,
voteAverage: entry.vote_average,
voteCount: entry.vote_count,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning()
.get();
if (row) titleIdMap.set(entry.id, row.id);
}
});
const shellEntries = newEntries
.filter((entry, index, arr) => arr.findIndex((e) => e.id === entry.id) === index)
.map((entry) => ({
tmdbId: entry.id,
mediaType: entry.media_type as "movie" | "tv",
title: entry.title ?? entry.name ?? "Unknown",
overview: entry.overview,
releaseDate: entry.release_date,
firstAirDate: entry.first_air_date,
posterPath: entry.poster_path,
backdropPath: entry.backdrop_path,
popularity: entry.popularity,
voteAverage: entry.vote_average,
voteCount: entry.vote_count,
}));
const stillMissing = [...insertedTmdbIds].filter((tmdbId) => !titleIdMap.has(tmdbId));
if (stillMissing.length > 0) {
const fallbacks = db
.select({ id: titles.id, tmdbId: titles.tmdbId })
.from(titles)
.where(inArray(titles.tmdbId, stillMissing))
.all();
for (const fallback of fallbacks) {
titleIdMap.set(fallback.tmdbId, fallback.id);
}
const updatedMap = batchInsertShellTitlesTransaction(shellEntries, titleIdMap);
for (const [tmdbId, id] of updatedMap) {
titleIdMap.set(tmdbId, id);
}
}
@@ -303,22 +264,11 @@ async function syncPersonFilmography(person: Pick<typeof persons.$inferSelect, "
}
const now = new Date();
db.transaction((tx) => {
tx.delete(personFilmography).where(eq(personFilmography.personId, person.id)).run();
for (const row of nextRows) {
tx.insert(personFilmography).values(row).run();
}
tx.update(persons)
.set({ filmographyLastFetchedAt: now })
.where(eq(persons.id, person.id))
.run();
});
replaceFilmographyTransaction(person.id, nextRows, now);
}
export async function fetchFullFilmography(personId: string): Promise<PersonCredit[]> {
const person = db.select().from(persons).where(eq(persons.id, personId)).get();
const person = getPersonById(personId);
if (!person) return [];
const localFilmography = getLocalFilmography(personId);
@@ -362,63 +312,5 @@ interface BrowsePersonInput {
* Returns a map of tmdbId → internal UUID.
*/
export function ensureBrowsePersonsExist(items: BrowsePersonInput[]): Map<number, string> {
if (items.length === 0) return new Map();
const unique = new Map<number, BrowsePersonInput>();
for (const item of items) {
if (!unique.has(item.tmdbId)) unique.set(item.tmdbId, item);
}
const tmdbIds = [...unique.keys()];
const existing = db
.select({ id: persons.id, tmdbId: persons.tmdbId })
.from(persons)
.where(inArray(persons.tmdbId, tmdbIds))
.all();
const result = new Map<number, string>();
for (const row of existing) {
result.set(row.tmdbId, row.id);
}
const missing = tmdbIds.filter((id) => !result.has(id));
if (missing.length === 0) return result;
db.transaction((tx) => {
for (const tmdbId of missing) {
const item = unique.get(tmdbId);
if (!item) continue;
const row = tx
.insert(persons)
.values({
tmdbId: item.tmdbId,
name: item.name,
profilePath: item.profilePath,
knownForDepartment: item.knownForDepartment ?? null,
popularity: item.popularity ?? null,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning({ id: persons.id })
.get();
if (row) {
result.set(tmdbId, row.id);
}
}
const stillMissing = missing.filter((id) => !result.has(id));
if (stillMissing.length > 0) {
const fallbacks = tx
.select({ id: persons.id, tmdbId: persons.tmdbId })
.from(persons)
.where(inArray(persons.tmdbId, stillMissing))
.all();
for (const f of fallbacks) {
result.set(f.tmdbId, f.id);
}
}
});
return result;
return ensureBrowsePersonsTransaction(items);
}
+8 -11
View File
@@ -1,22 +1,19 @@
import { db } from "@sofa/db/client";
import { count, eq } from "@sofa/db/helpers";
import { appSettings, user } from "@sofa/db/schema";
import {
getSettingValue,
getUserCount as queryGetUserCount,
upsertSetting,
} from "@sofa/db/queries/settings";
export function getSetting(key: string): string | null {
const row = db.select().from(appSettings).where(eq(appSettings.key, key)).get();
return row?.value ?? null;
return getSettingValue(key);
}
export function setSetting(key: string, value: string): void {
db.insert(appSettings)
.values({ key, value })
.onConflictDoUpdate({ target: appSettings.key, set: { value } })
.run();
upsertSetting(key, value);
}
export function getUserCount(): number {
const result = db.select({ count: count() }).from(user).get();
return result?.count ?? 0;
return queryGetUserCount();
}
export function getInstanceId(): string {
+5 -16
View File
@@ -2,9 +2,8 @@ import { access, constants, readdir } from "node:fs/promises";
import path from "node:path";
import { CACHE_DIR, DATA_DIR, DATABASE_URL, TMDB_API_BASE_URL } from "@sofa/config";
import { db } from "@sofa/db/client";
import { count, desc, eq } from "@sofa/db/helpers";
import { cronRuns, episodes, titles, user } from "@sofa/db/schema";
import { getLatestCronRun, getTableCounts } from "@sofa/db/queries/system-health";
import type { cronRuns } from "@sofa/db/schema";
import { listBackups } from "./backup";
import { imageCacheEnabled } from "./image-cache";
@@ -77,16 +76,12 @@ function getDatabaseHealth(): SystemHealthData["database"] {
walSizeBytes = Bun.file(`${DATABASE_URL}-wal`).size;
} catch {}
const [titleCount] = db.select({ count: count() }).from(titles).all();
const [episodeCount] = db.select({ count: count() }).from(episodes).all();
const [userCount] = db.select({ count: count() }).from(user).all();
const counts = getTableCounts();
return {
dbSizeBytes,
walSizeBytes,
titleCount: titleCount.count,
episodeCount: episodeCount.count,
userCount: userCount.count,
...counts,
};
}
@@ -161,13 +156,7 @@ function getJobsHealth(): SystemHealthData["jobs"] {
// Fetch only the latest cron run per job (index-optimized LIMIT 1 each)
const latestByJob = new Map<string, typeof cronRuns.$inferSelect>();
for (const jobName of JOB_NAMES) {
const latest = db
.select()
.from(cronRuns)
.where(eq(cronRuns.jobName, jobName))
.orderBy(desc(cronRuns.startedAt))
.limit(1)
.get();
const latest = getLatestCronRun(jobName);
if (latest) latestByJob.set(jobName, latest);
}
+1 -8
View File
@@ -1,6 +1,4 @@
import { db } from "@sofa/db/client";
import { count } from "@sofa/db/helpers";
import { titles } from "@sofa/db/schema";
import { getTitleCount } from "@sofa/db/queries/title";
import { createLogger } from "@sofa/logger";
import { imageCacheEnabled } from "./image-cache";
@@ -32,11 +30,6 @@ function bucketTitles(n: number): string {
return "501+";
}
function getTitleCount(): number {
const result = db.select({ count: count() }).from(titles).get();
return result?.count ?? 0;
}
export async function performTelemetryReport(): Promise<void> {
if (!isTelemetryEnabled()) {
log.debug("Telemetry disabled, skipping");
+12 -8
View File
@@ -3,9 +3,13 @@ import path from "node:path";
import sharp from "sharp";
import { rgbaToThumbHash } from "thumbhash";
import { db } from "@sofa/db/client";
import { eq } from "@sofa/db/helpers";
import { episodes, persons, seasons, titles } from "@sofa/db/schema";
import {
updateEpisodeStillThumbHash,
updatePersonProfileThumbHash,
updateSeasonPosterThumbHash,
updateTitleBackdropThumbHash,
updateTitlePosterThumbHash,
} from "@sofa/db/queries/thumbhash";
import { createLogger } from "@sofa/logger";
import type { ImageCategory } from "@sofa/tmdb/image";
@@ -48,7 +52,7 @@ export async function generateTitlePosterThumbHash(
sourceBuffer?: Buffer | null,
): Promise<string | null> {
const hash = posterPath ? await generateThumbHash(posterPath, "posters", sourceBuffer) : null;
db.update(titles).set({ posterThumbHash: hash }).where(eq(titles.id, titleId)).run();
updateTitlePosterThumbHash(titleId, hash);
return hash;
}
@@ -57,7 +61,7 @@ export async function generateTitleBackdropThumbHash(
backdropPath: string | null,
): Promise<string | null> {
const hash = backdropPath ? await generateThumbHash(backdropPath, "backdrops") : null;
db.update(titles).set({ backdropThumbHash: hash }).where(eq(titles.id, titleId)).run();
updateTitleBackdropThumbHash(titleId, hash);
return hash;
}
@@ -66,7 +70,7 @@ export async function generateSeasonThumbHash(
posterPath: string | null,
): Promise<string | null> {
const hash = posterPath ? await generateThumbHash(posterPath, "posters") : null;
db.update(seasons).set({ posterThumbHash: hash }).where(eq(seasons.id, seasonId)).run();
updateSeasonPosterThumbHash(seasonId, hash);
return hash;
}
@@ -75,7 +79,7 @@ export async function generateEpisodeThumbHash(
stillPath: string | null,
): Promise<string | null> {
const hash = stillPath ? await generateThumbHash(stillPath, "stills") : null;
db.update(episodes).set({ stillThumbHash: hash }).where(eq(episodes.id, episodeId)).run();
updateEpisodeStillThumbHash(episodeId, hash);
return hash;
}
@@ -84,6 +88,6 @@ export async function generatePersonThumbHash(
profilePath: string | null,
): Promise<string | null> {
const hash = profilePath ? await generateThumbHash(profilePath, "profiles") : null;
db.update(persons).set({ profileThumbHash: hash }).where(eq(persons.id, personId)).run();
updatePersonProfileThumbHash(personId, hash);
return hash;
}
+72 -279
View File
@@ -1,14 +1,26 @@
import { db } from "@sofa/db/client";
import { and, eq, inArray, sql } from "@sofa/db/helpers";
import { getTitleById } from "@sofa/db/queries/title";
import {
episodes,
seasons,
titles,
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "@sofa/db/schema";
batchInsertEpisodeWatchesTransaction,
batchInsertMissingEpisodeWatches,
countDistinctEpisodeWatches,
deleteEpisodeWatch,
deleteEpisodeWatches,
deleteRating,
deleteTitleStatus,
getAllEpisodeIdsForTitle,
getEpisodeProgressByTitleIds as getEpisodeProgressByTitleIdsQuery,
getEpisodeTitleId,
getExistingEpisodeWatchIds,
getSeasonById,
getSeasonEpisodes,
getTitleStatus,
getUserStatusesByTitleIds as getUserStatusesByTitleIdsQuery,
getUserTitleInfo as getUserTitleInfoQuery,
insertEpisodeWatch,
insertMovieWatch,
upsertRating,
upsertTitleStatus,
} from "@sofa/db/queries/tracking";
export function setTitleStatus(
userId: string,
@@ -17,20 +29,11 @@ export function setTitleStatus(
_source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
addedAt?: Date,
) {
const now = addedAt ?? new Date();
db.insert(userTitleStatus)
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
.onConflictDoUpdate({
target: [userTitleStatus.userId, userTitleStatus.titleId],
set: { status, updatedAt: now },
})
.run();
upsertTitleStatus(userId, titleId, status, addedAt);
}
export function removeTitleStatus(userId: string, titleId: string) {
db.delete(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
.run();
deleteTitleStatus(userId, titleId);
}
export function logMovieWatch(
@@ -40,14 +43,10 @@ export function logMovieWatch(
watchedAt?: Date,
) {
const now = watchedAt ?? new Date();
db.insert(userMovieWatches).values({ userId, titleId, watchedAt: now, source }).run();
insertMovieWatch(userId, titleId, now, source);
// Auto-set status to completed
const existing = db
.select()
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
.get();
const existing = getTitleStatus(userId, titleId);
if (!existing) {
setTitleStatus(userId, titleId, "completed", source);
@@ -63,24 +62,14 @@ export function logEpisodeWatch(
watchedAt?: Date,
) {
const now = watchedAt ?? new Date();
db.insert(userEpisodeWatches).values({ userId, episodeId, watchedAt: now, source }).run();
insertEpisodeWatch(userId, episodeId, now, source);
// Find the title for this episode (single JOIN instead of 2 queries)
const row = db
.select({ titleId: seasons.titleId })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(eq(episodes.id, episodeId))
.get();
if (!row) return;
const { titleId } = row;
const titleId = getEpisodeTitleId(episodeId);
if (!titleId) return;
// Auto-set status to in_progress if not set
const existing = db
.select()
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
.get();
const existing = getTitleStatus(userId, titleId);
if (!existing || existing.status === "watchlist") {
setTitleStatus(userId, titleId, "in_progress", source);
@@ -98,85 +87,7 @@ export function logEpisodeWatchBatch(
) {
if (episodeIds.length === 0) return;
db.transaction((tx) => {
const now = watchedAt ?? new Date();
// Batch INSERT all watch records
for (const episodeId of episodeIds) {
tx.insert(userEpisodeWatches).values({ userId, episodeId, watchedAt: now, source }).run();
}
// Resolve episode → season → title hierarchy with batch queries
const eps = tx.select().from(episodes).where(inArray(episodes.id, episodeIds)).all();
if (eps.length === 0) return;
const seasonIds = [...new Set(eps.map((e) => e.seasonId))];
const seasonRows = tx.select().from(seasons).where(inArray(seasons.id, seasonIds)).all();
if (seasonRows.length === 0) return;
const { titleId } = seasonRows[0];
// Set status to in_progress if not already set
const existing = tx
.select()
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
.get();
if (!existing || existing.status === "watchlist") {
const statusNow = new Date();
tx.insert(userTitleStatus)
.values({
userId,
titleId,
status: "in_progress",
addedAt: statusNow,
updatedAt: statusNow,
})
.onConflictDoUpdate({
target: [userTitleStatus.userId, userTitleStatus.titleId],
set: { status: "in_progress", updatedAt: statusNow },
})
.run();
}
// Check completion once (not per-episode)
const allSeasons = tx.select().from(seasons).where(eq(seasons.titleId, titleId)).all();
if (allSeasons.length === 0) return;
const allSeasonIds = allSeasons.map((s) => s.id);
const allEps = tx.select().from(episodes).where(inArray(episodes.seasonId, allSeasonIds)).all();
const totalEpisodes = allEps.length;
if (totalEpisodes === 0) return;
const allEpIds = allEps.map((ep) => ep.id);
const [watchCount] = tx
.select({
count: sql<number>`count(distinct ${userEpisodeWatches.episodeId})`,
})
.from(userEpisodeWatches)
.where(
and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, allEpIds)),
)
.all();
if (watchCount.count >= totalEpisodes) {
const completeNow = new Date();
tx.insert(userTitleStatus)
.values({
userId,
titleId,
status: "completed",
addedAt: completeNow,
updatedAt: completeNow,
})
.onConflictDoUpdate({
target: [userTitleStatus.userId, userTitleStatus.titleId],
set: { status: "completed", updatedAt: completeNow },
})
.run();
}
});
batchInsertEpisodeWatchesTransaction(userId, episodeIds, source, watchedAt);
}
export function markAllEpisodesWatched(
@@ -184,121 +95,58 @@ export function markAllEpisodesWatched(
titleId: string,
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
const title = getTitleById(titleId);
if (!title || title.type !== "tv") return;
const now = new Date();
// Single JOIN instead of seasons → episodes chain (2 queries → 1)
const allEps = db
.select({ id: episodes.id })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(eq(seasons.titleId, titleId))
.all();
const epIds = getAllEpisodeIdsForTitle(titleId);
const existingWatches = getExistingEpisodeWatchIds(userId, epIds);
const epIds = allEps.map((ep) => ep.id);
const existingWatches =
epIds.length > 0
? new Set(
db
.select({ episodeId: userEpisodeWatches.episodeId })
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
inArray(userEpisodeWatches.episodeId, epIds),
),
)
.all()
.map((w) => w.episodeId),
)
: new Set<string>();
db.transaction((tx) => {
for (const ep of allEps) {
if (!existingWatches.has(ep.id)) {
tx.insert(userEpisodeWatches)
.values({ userId, episodeId: ep.id, watchedAt: now, source })
.run();
}
}
});
batchInsertMissingEpisodeWatches(userId, epIds, existingWatches, source, now);
setTitleStatus(userId, titleId, "completed", source);
}
function checkAllEpisodesWatched(userId: string, titleId: string) {
// Single JOIN instead of seasons → episodes chain (2 queries → 1)
const allEps = db
.select({ id: episodes.id })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(eq(seasons.titleId, titleId))
.all();
const epIds = getAllEpisodeIdsForTitle(titleId);
const totalEpisodes = allEps.length;
const totalEpisodes = epIds.length;
if (totalEpisodes === 0) return;
const epIds = allEps.map((ep) => ep.id);
const [watchCount] = db
.select({
count: sql<number>`count(distinct ${userEpisodeWatches.episodeId})`,
})
.from(userEpisodeWatches)
.where(and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, epIds)))
.all();
const watchCount = countDistinctEpisodeWatches(userId, epIds);
if (watchCount.count >= totalEpisodes) {
if (watchCount >= totalEpisodes) {
setTitleStatus(userId, titleId, "completed");
}
}
export function unwatchEpisode(userId: string, episodeId: string) {
db.delete(userEpisodeWatches)
.where(and(eq(userEpisodeWatches.userId, userId), eq(userEpisodeWatches.episodeId, episodeId)))
.run();
deleteEpisodeWatch(userId, episodeId);
// Find parent title and downgrade from completed to in_progress
const row = db
.select({ titleId: seasons.titleId })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(eq(episodes.id, episodeId))
.get();
if (!row) return;
const titleId = getEpisodeTitleId(episodeId);
if (!titleId) return;
const existing = db
.select()
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, row.titleId)))
.get();
const existing = getTitleStatus(userId, titleId);
if (existing?.status === "completed") {
setTitleStatus(userId, row.titleId, "in_progress");
setTitleStatus(userId, titleId, "in_progress");
}
}
export function unwatchSeason(userId: string, seasonId: string) {
const seasonEps = db.select().from(episodes).where(eq(episodes.seasonId, seasonId)).all();
const seasonEps = getSeasonEpisodes(seasonId);
const epIds = seasonEps.map((ep) => ep.id);
if (epIds.length > 0) {
db.delete(userEpisodeWatches)
.where(
and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, epIds)),
)
.run();
deleteEpisodeWatches(userId, epIds);
}
// Find parent title and downgrade from completed to in_progress
const season = db.select().from(seasons).where(eq(seasons.id, seasonId)).get();
const season = getSeasonById(seasonId);
if (!season) return;
const existing = db
.select()
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, season.titleId)))
.get();
const existing = getTitleStatus(userId, season.titleId);
if (existing?.status === "completed") {
setTitleStatus(userId, season.titleId, "in_progress");
@@ -313,18 +161,10 @@ export function rateTitleStars(
) {
const now = ratedAt ?? new Date();
if (ratingStars === 0) {
db.delete(userRatings)
.where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)))
.run();
deleteRating(userId, titleId);
return;
}
db.insert(userRatings)
.values({ userId, titleId, ratingStars, ratedAt: now })
.onConflictDoUpdate({
target: [userRatings.userId, userRatings.titleId],
set: { ratingStars, ratedAt: now },
})
.run();
upsertRating(userId, titleId, ratingStars, now);
}
export function getUserStatusesByTitleIds(
@@ -333,14 +173,7 @@ export function getUserStatusesByTitleIds(
): Record<string, "watchlist" | "in_progress" | "completed"> {
if (titleIds.length === 0) return {};
const rows = db
.select({
titleId: userTitleStatus.titleId,
status: userTitleStatus.status,
})
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), inArray(userTitleStatus.titleId, titleIds)))
.all();
const rows = getUserStatusesByTitleIdsQuery(userId, titleIds);
const result: Record<string, "watchlist" | "in_progress" | "completed"> = {};
for (const row of rows) {
@@ -355,25 +188,7 @@ export function getEpisodeProgressByTitleIds(
): Record<string, { watched: number; total: number }> {
if (titleIds.length === 0) return {};
const rows = db
.select({
titleId: titles.id,
totalEpisodes: sql<number>`count(distinct ${episodes.id})`.as("totalEpisodes"),
watchedEpisodes:
sql<number>`count(distinct case when ${userEpisodeWatches.id} is not null then ${episodes.id} end)`.as(
"watchedEpisodes",
),
})
.from(titles)
.innerJoin(seasons, eq(seasons.titleId, titles.id))
.innerJoin(episodes, eq(episodes.seasonId, seasons.id))
.leftJoin(
userEpisodeWatches,
and(eq(userEpisodeWatches.episodeId, episodes.id), eq(userEpisodeWatches.userId, userId)),
)
.where(and(inArray(titles.id, titleIds), eq(titles.type, "tv")))
.groupBy(titles.id)
.all();
const rows = getEpisodeProgressByTitleIdsQuery(userId, titleIds);
const result: Record<string, { watched: number; total: number }> = {};
for (const row of rows) {
@@ -388,51 +203,29 @@ export function getEpisodeProgressByTitleIds(
}
export function getUserTitleInfo(userId: string, titleId: string) {
const status = db
.select()
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
.get();
return getUserTitleInfoQuery(userId, titleId);
}
const rating = db
.select()
.from(userRatings)
.where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)))
.get();
export function quickAddTitle(
userId: string,
titleId: string,
): { id: string; tmdbId: number; type: string; alreadyAdded: boolean } | null {
const title = getTitleById(titleId);
if (!title) return null;
// Single JOIN instead of seasons → episodes chain (2 queries → 1)
const allEps = db
.select({ id: episodes.id })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(eq(seasons.titleId, titleId))
.all();
const existing = getTitleStatus(userId, titleId);
const epIds = allEps.map((ep) => ep.id);
if (!existing) {
setTitleStatus(userId, titleId, "watchlist");
}
// Batch fetch all watches for these episodes
const watchedEpisodeIds =
epIds.length > 0
? Array.from(
new Set(
db
.select({ episodeId: userEpisodeWatches.episodeId })
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
inArray(userEpisodeWatches.episodeId, epIds),
),
)
.all()
.map((w) => w.episodeId),
),
)
: [];
return { id: title.id, tmdbId: title.tmdbId, type: title.type, alreadyAdded: !!existing };
}
return {
status: status?.status ?? null,
rating: rating?.ratingStars ?? null,
episodeWatches: watchedEpisodeIds,
};
export function watchSeason(userId: string, seasonId: string): void {
const seasonEps = getSeasonEpisodes(seasonId);
logEpisodeWatchBatch(
userId,
seasonEps.map((ep) => ep.id),
);
}
+25 -63
View File
@@ -1,13 +1,10 @@
import { db } from "@sofa/db/client";
import { and, eq, gte } from "@sofa/db/helpers";
import { findEpisodeBySeasonAndNumber, findSeasonByTitleAndNumber } from "@sofa/db/queries/title";
import {
episodes,
integrationEvents,
integrations,
seasons,
userEpisodeWatches,
userMovieWatches,
} from "@sofa/db/schema";
getRecentEpisodeWatch,
getRecentMovieWatch,
insertIntegrationEvent,
updateIntegrationLastEvent,
} from "@sofa/db/queries/webhooks";
import { createLogger } from "@sofa/logger";
import { resolveMovieTmdbId, resolveShowTmdbId } from "./imports/resolve";
@@ -173,33 +170,13 @@ async function resolveEpisode(event: WebhookEvent): Promise<{
function isDuplicateMovieWatch(userId: string, titleId: string): boolean {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
const recent = db
.select()
.from(userMovieWatches)
.where(
and(
eq(userMovieWatches.userId, userId),
eq(userMovieWatches.titleId, titleId),
gte(userMovieWatches.watchedAt, fiveMinutesAgo),
),
)
.get();
const recent = getRecentMovieWatch(userId, titleId, fiveMinutesAgo);
return !!recent;
}
function isDuplicateEpisodeWatch(userId: string, episodeId: string): boolean {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
const recent = db
.select()
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
eq(userEpisodeWatches.episodeId, episodeId),
gte(userEpisodeWatches.watchedAt, fiveMinutesAgo),
),
)
.get();
const recent = getRecentEpisodeWatch(userId, episodeId, fiveMinutesAgo);
return !!recent;
}
@@ -211,27 +188,22 @@ function logEvent(
status: "success" | "ignored" | "error",
errorMessage?: string,
) {
db.insert(integrationEvents)
.values({
integrationId: connectionId,
eventType:
event?.provider === "plex"
? "media.scrobble"
: event?.provider === "emby"
? "playback.stop"
: "PlaybackStop",
mediaType: event?.mediaType ?? null,
mediaTitle: event?.title ?? null,
status,
errorMessage: errorMessage ?? null,
receivedAt: new Date(),
})
.run();
insertIntegrationEvent({
integrationId: connectionId,
eventType:
event?.provider === "plex"
? "media.scrobble"
: event?.provider === "emby"
? "playback.stop"
: "PlaybackStop",
mediaType: event?.mediaType ?? null,
mediaTitle: event?.title ?? null,
status,
errorMessage: errorMessage ?? null,
receivedAt: new Date(),
});
db.update(integrations)
.set({ lastEventAt: new Date() })
.where(eq(integrations.id, connectionId))
.run();
updateIntegrationLastEvent(connectionId);
}
// ─── Main Processing ────────────────────────────────────────────────
@@ -293,11 +265,7 @@ export async function processWebhook(
}
// Find the episode in our DB
const season = db
.select()
.from(seasons)
.where(and(eq(seasons.titleId, title.id), eq(seasons.seasonNumber, resolved.seasonNumber)))
.get();
const season = findSeasonByTitleAndNumber(title.id, resolved.seasonNumber);
if (!season) {
logEvent(connectionId, event, "error", `Season ${resolved.seasonNumber} not found`);
@@ -307,13 +275,7 @@ export async function processWebhook(
};
}
const episode = db
.select()
.from(episodes)
.where(
and(eq(episodes.seasonId, season.id), eq(episodes.episodeNumber, resolved.episodeNumber)),
)
.get();
const episode = findEpisodeBySeasonAndNumber(season.id, resolved.episodeNumber);
if (!episode) {
logEvent(
+1 -1
View File
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, spyOn, test } from "bun:test";
import { eq } from "@sofa/db/helpers";
import {
importJobs,
titles,
@@ -11,6 +10,7 @@ import {
} from "@sofa/db/schema";
import {
clearAllTables,
eq,
insertMovieWatch,
insertTvShow,
insertUser,
+1 -2
View File
@@ -1,8 +1,7 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import { eq } from "@sofa/db/helpers";
import { episodes, seasons, titles } from "@sofa/db/schema";
import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils";
import { clearAllTables, eq, insertTitle, testDb } from "@sofa/db/test-utils";
interface MockSeasonEpisode {
episode_number: number;
+1 -2
View File
@@ -1,8 +1,7 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
import { eq } from "@sofa/db/helpers";
import { personFilmography, persons } from "@sofa/db/schema";
import { clearAllTables, testDb } from "@sofa/db/test-utils";
import { clearAllTables, eq, testDb } from "@sofa/db/test-utils";
const TINY_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4//8/AwAI/AL+X2NDNwAAAABJRU5ErkJggg==",
+1 -2
View File
@@ -1,8 +1,7 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
import { eq } from "@sofa/db/helpers";
import { episodes, seasons, titles } from "@sofa/db/schema";
import { clearAllTables, testDb } from "@sofa/db/test-utils";
import { clearAllTables, eq, testDb } from "@sofa/db/test-utils";
const TINY_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4//8/AwAI/AL+X2NDNwAAAABJRU5ErkJggg==",
+9 -2
View File
@@ -1,13 +1,20 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { and, eq } from "@sofa/db/helpers";
import {
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "@sofa/db/schema";
import { clearAllTables, insertTitle, insertTvShow, insertUser, testDb } from "@sofa/db/test-utils";
import {
and,
clearAllTables,
eq,
insertTitle,
insertTvShow,
insertUser,
testDb,
} from "@sofa/db/test-utils";
import {
getUserTitleInfo,
+4 -3
View File
@@ -5,10 +5,11 @@
"type": "module",
"exports": {
"./client": "./src/client.ts",
"./schema": "./src/schema.ts",
"./helpers": "./src/helpers.ts",
"./migrate": "./src/migrate.ts",
"./test-utils": "./src/test-utils.ts"
"./schema": "./src/schema.ts",
"./test-utils": "./src/test-utils.ts",
"./utils": "./src/utils.ts",
"./queries/*": "./src/queries/*.ts"
},
"scripts": {
"lint": "oxlint",
-14
View File
@@ -1,14 +0,0 @@
export {
and,
count,
desc,
eq,
gte,
inArray,
isNotNull,
isNull,
lt,
notInArray,
or,
sql,
} from "drizzle-orm";
+24
View File
@@ -0,0 +1,24 @@
import { and, eq } from "drizzle-orm";
import { db } from "../client";
import { availabilityOffers } from "../schema";
export function replaceAvailabilityTransaction(
titleId: string,
region: string,
offers: (typeof availabilityOffers.$inferInsert)[],
): void {
db.transaction((tx) => {
tx.delete(availabilityOffers)
.where(and(eq(availabilityOffers.titleId, titleId), eq(availabilityOffers.region, region)))
.run();
if (offers.length > 0) {
tx.insert(availabilityOffers).values(offers).onConflictDoNothing().run();
}
});
}
export function getAvailabilityOffers(titleId: string) {
return db.select().from(availabilityOffers).where(eq(availabilityOffers.titleId, titleId)).all();
}
+61
View File
@@ -0,0 +1,61 @@
import { inArray, isNull, notInArray } from "drizzle-orm";
import { db } from "../client";
import { persons, titleCast, titles, userTitleStatus } from "../schema";
const BATCH_SIZE = 500;
export function purgeShellTitlesTransaction(): { deletedTitles: number; deletedPersons: number } {
return db.transaction(() => {
const shellTitles = db
.select({ id: titles.id })
.from(titles)
.where(isNull(titles.lastFetchedAt))
.all();
if (shellTitles.length === 0) {
return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons() };
}
const libraryTitleIds = new Set(
db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.all()
.map((r) => r.titleId),
);
const toDelete = shellTitles.map((t) => t.id).filter((id) => !libraryTitleIds.has(id));
if (toDelete.length === 0) {
return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons() };
}
for (let i = 0; i < toDelete.length; i += BATCH_SIZE) {
const batch = toDelete.slice(i, i + BATCH_SIZE);
db.delete(titles).where(inArray(titles.id, batch)).run();
}
return { deletedTitles: toDelete.length, deletedPersons: purgeOrphanedPersons() };
});
}
export function purgeOrphanedPersons(): number {
const orphanedPersons = db
.select({ id: persons.id })
.from(persons)
.where(
notInArray(persons.id, db.selectDistinct({ personId: titleCast.personId }).from(titleCast)),
)
.all();
if (orphanedPersons.length === 0) return 0;
const ids = orphanedPersons.map((p) => p.id);
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
const batch = ids.slice(i, i + BATCH_SIZE);
db.delete(persons).where(inArray(persons.id, batch)).run();
}
return ids.length;
}
+8
View File
@@ -0,0 +1,8 @@
import { eq } from "drizzle-orm";
import { db } from "../client";
import { titles } from "../schema";
export function updateTitleColorPalette(titleId: string, palette: string | null): void {
db.update(titles).set({ colorPalette: palette }).where(eq(titles.id, titleId)).run();
}
+138
View File
@@ -0,0 +1,138 @@
import { eq, inArray, sql } from "drizzle-orm";
import { db } from "../client";
import { persons, titleCast } from "../schema";
interface PersonData {
tmdbId: number;
name: string;
profilePath: string | null;
popularity?: number;
}
export function getExistingPersonsByTmdbIds(tmdbIds: number[]) {
if (tmdbIds.length === 0) return [];
return db
.select({
id: persons.id,
tmdbId: persons.tmdbId,
name: persons.name,
profilePath: persons.profilePath,
profileThumbHash: persons.profileThumbHash,
popularity: persons.popularity,
})
.from(persons)
.where(inArray(persons.tmdbId, tmdbIds))
.all();
}
export function batchUpsertPersonsTransaction(
uniquePeople: PersonData[],
existingMap: Map<number, ReturnType<typeof getExistingPersonsByTmdbIds>[number]>,
): Map<number, string> {
const idMap = new Map<number, string>();
for (const [tmdbId, p] of existingMap) {
idMap.set(tmdbId, p.id);
}
db.transaction((tx) => {
for (const p of uniquePeople) {
const existingPerson = existingMap.get(p.tmdbId);
if (existingPerson) {
const nextPopularity = p.popularity ?? null;
const pathChanged = existingPerson.profilePath !== p.profilePath;
const needsUpdate =
existingPerson.name !== p.name ||
pathChanged ||
existingPerson.popularity !== nextPopularity;
if (!needsUpdate) continue;
tx.update(persons)
.set({
name: p.name,
profilePath: p.profilePath,
popularity: nextPopularity,
profileThumbHash: pathChanged ? null : existingPerson.profileThumbHash,
})
.where(eq(persons.id, existingPerson.id))
.run();
continue;
}
const row = tx
.insert(persons)
.values({
tmdbId: p.tmdbId,
name: p.name,
profilePath: p.profilePath,
popularity: p.popularity ?? null,
})
.onConflictDoNothing()
.returning()
.get();
if (row) idMap.set(p.tmdbId, row.id);
}
});
return idMap;
}
export function getFallbackPersonsByTmdbIds(tmdbIds: number[]) {
if (tmdbIds.length === 0) return [];
return db
.select({ id: persons.id, tmdbId: persons.tmdbId })
.from(persons)
.where(inArray(persons.tmdbId, tmdbIds))
.all();
}
export function getPersonsForTitleCast(titleId: string) {
return db
.select({
id: persons.id,
profilePath: persons.profilePath,
profileThumbHash: persons.profileThumbHash,
})
.from(titleCast)
.innerJoin(persons, eq(titleCast.personId, persons.id))
.where(eq(titleCast.titleId, titleId))
.all();
}
export function batchUpsertTitleCast(rows: (typeof titleCast.$inferInsert)[]): void {
if (rows.length === 0) return;
db.insert(titleCast)
.values(rows)
.onConflictDoUpdate({
target: [titleCast.titleId, titleCast.personId, titleCast.department, titleCast.character],
set: {
job: sql`excluded.job`,
displayOrder: sql`excluded.displayOrder`,
episodeCount: sql`excluded.episodeCount`,
lastFetchedAt: sql`excluded.lastFetchedAt`,
},
})
.run();
}
export function getCastForTitleJoined(titleId: string) {
return db
.select({
id: titleCast.id,
personId: titleCast.personId,
name: persons.name,
character: titleCast.character,
department: titleCast.department,
job: titleCast.job,
displayOrder: titleCast.displayOrder,
episodeCount: titleCast.episodeCount,
profilePath: persons.profilePath,
profileThumbHash: persons.profileThumbHash,
tmdbId: persons.tmdbId,
})
.from(titleCast)
.innerJoin(persons, eq(titleCast.personId, persons.id))
.where(eq(titleCast.titleId, titleId))
.orderBy(titleCast.displayOrder)
.all();
}
+175
View File
@@ -0,0 +1,175 @@
import { and, eq, inArray, isNotNull, lt, or, sql } from "drizzle-orm";
import { db } from "../client";
import {
availabilityOffers,
cronRuns,
episodes,
persons,
seasons,
titleCast,
titles,
userTitleStatus,
} from "../schema";
export function insertCronRunReturning(jobName: string) {
return db
.insert(cronRuns)
.values({ jobName, status: "running", startedAt: new Date() })
.returning()
.get();
}
export function updateCronRunSuccess(id: string, durationMs: number): void {
db.update(cronRuns)
.set({ status: "success", finishedAt: new Date(), durationMs })
.where(eq(cronRuns.id, id))
.run();
}
export function updateCronRunError(id: string, durationMs: number, errorMessage: string): void {
db.update(cronRuns)
.set({ status: "error", finishedAt: new Date(), durationMs, errorMessage })
.where(eq(cronRuns.id, id))
.run();
}
export function getLibraryTitleIds(): string[] {
return db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.groupBy(userTitleStatus.titleId)
.all()
.map((r) => r.titleId);
}
export function getTitlesWithMissingThumbhashes() {
return db
.select({ id: titles.id })
.from(titles)
.where(
or(
and(isNotNull(titles.posterPath), sql`${titles.posterThumbHash} IS NULL`),
and(isNotNull(titles.backdropPath), sql`${titles.backdropThumbHash} IS NULL`),
),
)
.all()
.map((row) => row.id);
}
export function getTitleIdsWithMissingSeasonThumbhashes() {
return db
.select({ titleId: seasons.titleId })
.from(seasons)
.where(and(isNotNull(seasons.posterPath), sql`${seasons.posterThumbHash} IS NULL`))
.groupBy(seasons.titleId)
.all()
.map((row) => row.titleId);
}
export function getTitleIdsWithMissingEpisodeThumbhashes() {
return db
.select({ titleId: seasons.titleId })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(and(isNotNull(episodes.stillPath), sql`${episodes.stillThumbHash} IS NULL`))
.groupBy(seasons.titleId)
.all()
.map((row) => row.titleId);
}
export function getTitleIdsWithMissingProfileThumbhashes() {
return db
.select({ titleId: titleCast.titleId })
.from(titleCast)
.innerJoin(persons, eq(titleCast.personId, persons.id))
.where(and(isNotNull(persons.profilePath), sql`${persons.profileThumbHash} IS NULL`))
.groupBy(titleCast.titleId)
.all()
.map((row) => row.titleId);
}
export function getStaleTitles(titleIds: string[], staleDate: Date) {
if (titleIds.length === 0) return [];
return db
.select({ id: titles.id })
.from(titles)
.where(and(inArray(titles.id, titleIds), lt(titles.lastFetchedAt, staleDate)))
.all();
}
export function getStaleNonLibraryTitles(staleDate: Date, limit: number) {
return db
.select()
.from(titles)
.where(and(isNotNull(titles.lastFetchedAt), lt(titles.lastFetchedAt, staleDate)))
.limit(limit)
.all();
}
export function getTitlesWithStaleOffers(titleIds: string[]) {
if (titleIds.length === 0) return new Set<string>();
return new Set(
db
.select({ titleId: availabilityOffers.titleId })
.from(availabilityOffers)
.where(inArray(availabilityOffers.titleId, titleIds))
.groupBy(availabilityOffers.titleId)
.all()
.map((r) => r.titleId),
);
}
export function getTitlesWithStaleOffersFetchedBefore(titleIds: string[], staleDate: Date) {
if (titleIds.length === 0) return new Set<string>();
return new Set(
db
.select({ titleId: availabilityOffers.titleId })
.from(availabilityOffers)
.where(
and(
inArray(availabilityOffers.titleId, titleIds),
lt(availabilityOffers.lastFetchedAt, staleDate),
),
)
.groupBy(availabilityOffers.titleId)
.all()
.map((r) => r.titleId),
);
}
export function getReturningTvShows() {
const returningStatuses = ["Returning Series", "In Production"];
return db
.select()
.from(titles)
.where(
and(
eq(titles.type, "tv"),
isNotNull(titles.lastFetchedAt),
or(...returningStatuses.map((s) => eq(titles.status, s))),
),
)
.all();
}
export function getTitleIdsWithStaleSeasons(titleIds: string[], staleDate: Date) {
if (titleIds.length === 0) return new Set<string>();
return new Set(
db
.select({ titleId: seasons.titleId })
.from(seasons)
.where(and(inArray(seasons.titleId, titleIds), lt(seasons.lastFetchedAt, staleDate)))
.groupBy(seasons.titleId)
.all()
.map((r) => r.titleId),
);
}
export function getTitleByIdForCron(titleId: string) {
return db.select().from(titles).where(eq(titles.id, titleId)).get();
}
export function getCastEntryForTitle(titleId: string) {
return db.select().from(titleCast).where(eq(titleCast.titleId, titleId)).limit(1).get();
}
+270
View File
@@ -0,0 +1,270 @@
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { db } from "../client";
import {
availabilityOffers,
episodes,
seasons,
titleRecommendations,
titles,
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "../schema";
export function getMovieWatchCountSince(userId: string, since: number) {
const [row] = db
.select({ count: sql<number>`count(*)` })
.from(userMovieWatches)
.where(and(eq(userMovieWatches.userId, userId), sql`${userMovieWatches.watchedAt} >= ${since}`))
.all();
return row?.count ?? 0;
}
export function getEpisodeWatchCountSince(userId: string, since: number) {
const [row] = db
.select({ count: sql<number>`count(*)` })
.from(userEpisodeWatches)
.where(
and(eq(userEpisodeWatches.userId, userId), sql`${userEpisodeWatches.watchedAt} >= ${since}`),
)
.all();
return row?.count ?? 0;
}
export function getMovieWatchHistoryBuckets(userId: string, startTs: number, format: string) {
return db
.select({
bucket:
sql<string>`strftime(${format}, ${userMovieWatches.watchedAt}, 'unixepoch', 'localtime')`.as(
"bucket",
),
count: sql<number>`count(*)`.as("cnt"),
})
.from(userMovieWatches)
.where(
and(eq(userMovieWatches.userId, userId), sql`${userMovieWatches.watchedAt} >= ${startTs}`),
)
.groupBy(sql`strftime(${format}, ${userMovieWatches.watchedAt}, 'unixepoch', 'localtime')`)
.all();
}
export function getEpisodeWatchHistoryBuckets(userId: string, startTs: number, format: string) {
return db
.select({
bucket:
sql<string>`strftime(${format}, ${userEpisodeWatches.watchedAt}, 'unixepoch', 'localtime')`.as(
"bucket",
),
count: sql<number>`count(*)`.as("cnt"),
})
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
sql`${userEpisodeWatches.watchedAt} >= ${startTs}`,
),
)
.groupBy(sql`strftime(${format}, ${userEpisodeWatches.watchedAt}, 'unixepoch', 'localtime')`)
.all();
}
export function getUserStatusCounts(userId: string) {
const [row] = db
.select({
librarySize: sql<number>`count(*)`,
completed: sql<number>`sum(case when ${userTitleStatus.status} = 'completed' then 1 else 0 end)`,
})
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all();
return row;
}
export function getInProgressTitleIds(userId: string) {
return db
.select({
titleId: userTitleStatus.titleId,
updatedAt: userTitleStatus.updatedAt,
})
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.status, "in_progress")))
.all();
}
export function getTvTitlesByIds(titleIds: string[]) {
if (titleIds.length === 0) return [];
return db
.select()
.from(titles)
.where(and(inArray(titles.id, titleIds), eq(titles.type, "tv")))
.all();
}
export function getSeasonsByTitleIds(titleIds: string[]) {
if (titleIds.length === 0) return [];
return db
.select()
.from(seasons)
.where(inArray(seasons.titleId, titleIds))
.orderBy(seasons.titleId, seasons.seasonNumber)
.all();
}
export function getEpisodesBySeasonIds(seasonIds: string[]) {
if (seasonIds.length === 0) return [];
return db
.select()
.from(episodes)
.where(inArray(episodes.seasonId, seasonIds))
.orderBy(episodes.seasonId, episodes.episodeNumber)
.all();
}
export function getEpisodeWatchesByEpisodeIds(userId: string, episodeIds: string[]) {
if (episodeIds.length === 0) return [];
return db
.select()
.from(userEpisodeWatches)
.where(
and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, episodeIds)),
)
.all();
}
export function getNewAvailableFeed(userId: string, _days = 14) {
return db
.select({
titleId: titles.id,
title: titles.title,
type: titles.type,
tmdbId: titles.tmdbId,
posterPath: titles.posterPath,
posterThumbHash: titles.posterThumbHash,
releaseDate: titles.releaseDate,
firstAirDate: titles.firstAirDate,
voteAverage: titles.voteAverage,
popularity: titles.popularity,
userStatus: userTitleStatus.status,
})
.from(titles)
.innerJoin(
userTitleStatus,
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
)
.where(
sql`EXISTS (SELECT 1 FROM ${availabilityOffers} WHERE ${availabilityOffers.titleId} = ${titles.id})`,
)
.orderBy(desc(titles.popularity))
.limit(20)
.all();
}
export function getLibraryFeed(userId: string, page = 1, limit = 20) {
const offset = (page - 1) * limit;
const availabilityFilter = sql`EXISTS (SELECT 1 FROM ${availabilityOffers} WHERE ${availabilityOffers.titleId} = ${titles.id})`;
const joinCondition = and(
eq(userTitleStatus.titleId, titles.id),
eq(userTitleStatus.userId, userId),
);
const [{ count }] = db
.select({ count: sql<number>`count(*)` })
.from(titles)
.innerJoin(userTitleStatus, joinCondition)
.where(availabilityFilter)
.all();
const items = db
.select({
titleId: titles.id,
title: titles.title,
type: titles.type,
tmdbId: titles.tmdbId,
posterPath: titles.posterPath,
posterThumbHash: titles.posterThumbHash,
releaseDate: titles.releaseDate,
firstAirDate: titles.firstAirDate,
voteAverage: titles.voteAverage,
popularity: titles.popularity,
userStatus: userTitleStatus.status,
})
.from(titles)
.innerJoin(userTitleStatus, joinCondition)
.where(availabilityFilter)
.orderBy(desc(titles.popularity))
.limit(limit)
.offset(offset)
.all();
const totalResults = count ?? 0;
return {
items,
page,
totalPages: Math.max(1, Math.ceil(totalResults / limit)),
totalResults,
};
}
export function getCompletedTitleIds(userId: string) {
return db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.status, "completed")))
.all()
.map((r) => r.titleId);
}
export function getHighlyRatedTitleIds(userId: string) {
return db
.select({ titleId: userRatings.titleId })
.from(userRatings)
.where(and(eq(userRatings.userId, userId), sql`${userRatings.ratingStars} >= 4`))
.all()
.map((r) => r.titleId);
}
export function getAllTrackedTitleIds(userId: string) {
return db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all()
.map((r) => r.titleId);
}
export function getRecommendationRows(sourceIds: string[]) {
if (sourceIds.length === 0) return [];
return db
.select({
recommendedTitleId: titleRecommendations.recommendedTitleId,
rank: titleRecommendations.rank,
})
.from(titleRecommendations)
.where(inArray(titleRecommendations.titleId, sourceIds))
.all();
}
export function getTitlesByIds(titleIds: string[]) {
if (titleIds.length === 0) return [];
return db.select().from(titles).where(inArray(titles.id, titleIds)).all();
}
export function getRecommendationRowsForTitle(titleId: string) {
return db
.select({
recommendedTitleId: titleRecommendations.recommendedTitleId,
source: titleRecommendations.source,
rank: titleRecommendations.rank,
})
.from(titleRecommendations)
.where(eq(titleRecommendations.titleId, titleId))
.orderBy(titleRecommendations.rank)
.all();
}
export function getTitleByIdOrNull(titleId: string) {
return db.select().from(titles).where(eq(titles.id, titleId)).get() ?? null;
}
+69
View File
@@ -0,0 +1,69 @@
import { eq, inArray } from "drizzle-orm";
import { db } from "../client";
import { availabilityOffers, episodes, persons, seasons, titleCast, titles } from "../schema";
// ─── Title image paths ───────────────────────────────────────────────
export function getTitleWithPaths(titleId: string) {
return db
.select({
id: titles.id,
posterPath: titles.posterPath,
backdropPath: titles.backdropPath,
type: titles.type,
})
.from(titles)
.where(eq(titles.id, titleId))
.get();
}
// ─── Season posters ──────────────────────────────────────────────────
export function getSeasonPostersForTitle(titleId: string) {
return db
.select({ posterPath: seasons.posterPath })
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
}
// ─── Episode stills ──────────────────────────────────────────────────
export function getEpisodeStillsForTitle(titleId: string) {
const allSeasons = db
.select({ id: seasons.id })
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
const seasonIds = allSeasons.map((s) => s.id);
if (seasonIds.length === 0) return [];
return db
.select({ stillPath: episodes.stillPath })
.from(episodes)
.where(inArray(episodes.seasonId, seasonIds))
.all();
}
// ─── Availability logos ──────────────────────────────────────────────
export function getAvailabilityLogosForTitle(titleId: string) {
return db
.select({ logoPath: availabilityOffers.logoPath })
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, titleId))
.all();
}
// ─── Cast profile paths ─────────────────────────────────────────────
export function getCastProfilePathsForTitle(titleId: string) {
return db
.select({ profilePath: persons.profilePath })
.from(titleCast)
.innerJoin(persons, eq(titleCast.personId, persons.id))
.where(eq(titleCast.titleId, titleId))
.all();
}
+81
View File
@@ -0,0 +1,81 @@
import { and, eq, inArray } from "drizzle-orm";
import { db } from "../client";
import {
importJobs,
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "../schema";
// ─── Existence checks (deduplication) ────────────────────────────────
export function hasMovieWatch(userId: string, titleId: string): boolean {
const existing = db
.select({ id: userMovieWatches.id })
.from(userMovieWatches)
.where(and(eq(userMovieWatches.userId, userId), eq(userMovieWatches.titleId, titleId)))
.get();
return !!existing;
}
export function hasEpisodeWatch(userId: string, episodeId: string): boolean {
const existing = db
.select({ id: userEpisodeWatches.id })
.from(userEpisodeWatches)
.where(and(eq(userEpisodeWatches.userId, userId), eq(userEpisodeWatches.episodeId, episodeId)))
.get();
return !!existing;
}
export function hasTitleStatus(userId: string, titleId: string): boolean {
const existing = db
.select({ status: userTitleStatus.status })
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
.get();
return !!existing;
}
export function hasRating(userId: string, titleId: string): boolean {
const existing = db
.select({ ratingStars: userRatings.ratingStars })
.from(userRatings)
.where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)))
.get();
return !!existing;
}
// ─── Import job CRUD ─────────────────────────────────────────────────
export function getImportJob(jobId: string) {
return db.select().from(importJobs).where(eq(importJobs.id, jobId)).get();
}
export function updateImportJobProgress(
jobId: string,
values: Partial<typeof importJobs.$inferInsert>,
) {
db.update(importJobs).set(values).where(eq(importJobs.id, jobId)).run();
}
export function getImportJobStatus(jobId: string) {
return db
.select({ status: importJobs.status })
.from(importJobs)
.where(eq(importJobs.id, jobId))
.get();
}
export function insertImportJob(values: typeof importJobs.$inferInsert) {
return db.insert(importJobs).values(values).returning().get();
}
export function getActiveImportJobForUser(userId: string) {
return db
.select()
.from(importJobs)
.where(and(eq(importJobs.userId, userId), inArray(importJobs.status, ["pending", "running"])))
.get();
}
+58
View File
@@ -0,0 +1,58 @@
import { and, desc, eq } from "drizzle-orm";
import { db } from "../client";
import { integrationEvents, integrations } from "../schema";
export function getUserIntegrations(userId: string) {
return db.select().from(integrations).where(eq(integrations.userId, userId)).all();
}
export function getRecentEventsForIntegration(integrationId: string, limit = 10) {
return db
.select()
.from(integrationEvents)
.where(eq(integrationEvents.integrationId, integrationId))
.orderBy(desc(integrationEvents.receivedAt))
.limit(limit)
.all();
}
export function getIntegrationByUserAndProvider(userId: string, provider: string) {
return db
.select()
.from(integrations)
.where(and(eq(integrations.userId, userId), eq(integrations.provider, provider)))
.get();
}
export function updateIntegrationEnabled(integrationId: string, enabled: boolean) {
return db
.update(integrations)
.set({ enabled })
.where(eq(integrations.id, integrationId))
.returning()
.get();
}
export function insertIntegration(values: typeof integrations.$inferInsert) {
return db.insert(integrations).values(values).returning().get();
}
export function deleteIntegrationByUserAndProvider(userId: string, provider: string): void {
db.delete(integrations)
.where(and(eq(integrations.userId, userId), eq(integrations.provider, provider)))
.run();
}
export function regenerateIntegrationToken(userId: string, provider: string, newToken: string) {
return db
.update(integrations)
.set({ token: newToken })
.where(and(eq(integrations.userId, userId), eq(integrations.provider, provider)))
.returning()
.get();
}
export function getIntegrationByToken(token: string) {
return db.select().from(integrations).where(eq(integrations.token, token)).get();
}
+71
View File
@@ -0,0 +1,71 @@
import { and, eq, inArray } from "drizzle-orm";
import { db } from "../client";
import { integrations, titles, userTitleStatus } from "../schema";
export function resolveListIntegration(token: string) {
return db
.select({
userId: integrations.userId,
provider: integrations.provider,
})
.from(integrations)
.where(
and(
eq(integrations.token, token),
eq(integrations.type, "list"),
eq(integrations.enabled, true),
),
)
.get();
}
export function getRadarrMovies(
userId: string,
statuses: ("watchlist" | "in_progress" | "completed")[],
) {
return db
.select({ tmdbId: titles.tmdbId })
.from(userTitleStatus)
.innerJoin(titles, eq(userTitleStatus.titleId, titles.id))
.where(
and(
eq(userTitleStatus.userId, userId),
eq(titles.type, "movie"),
inArray(userTitleStatus.status, statuses),
),
)
.all();
}
export function getSonarrShows(
userId: string,
statuses: ("watchlist" | "in_progress" | "completed")[],
) {
return db
.select({
id: titles.id,
tmdbId: titles.tmdbId,
tvdbId: titles.tvdbId,
title: titles.title,
})
.from(userTitleStatus)
.innerJoin(titles, eq(userTitleStatus.titleId, titles.id))
.where(
and(
eq(userTitleStatus.userId, userId),
eq(titles.type, "tv"),
inArray(userTitleStatus.status, statuses),
),
)
.all();
}
export function batchUpdateTvdbIds(updates: { titleId: string; tvdbId: number }[]): void {
if (updates.length === 0) return;
db.transaction((tx) => {
for (const { titleId, tvdbId } of updates) {
tx.update(titles).set({ tvdbId }).where(eq(titles.id, titleId)).run();
}
});
}
+570
View File
@@ -0,0 +1,570 @@
import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
import { db } from "../client";
import {
availabilityOffers,
episodes,
genres,
seasons,
titleGenres,
titleRecommendations,
titles,
} from "../schema";
// ─── Title CRUD ──────────────────────────────────────────────────────
/**
* Insert a title row with returning, or return the existing row if a concurrent
* insert wins the race (catches SQLITE_CONSTRAINT_UNIQUE).
*/
export function insertTitleReturning(values: typeof titles.$inferInsert) {
try {
return db.insert(titles).values(values).returning().get();
} catch (err: unknown) {
if (err instanceof Error && "code" in err && err.code === "SQLITE_CONSTRAINT_UNIQUE") {
return db
.select()
.from(titles)
.where(and(eq(titles.tmdbId, values.tmdbId), eq(titles.type, values.type)))
.get();
}
throw err;
}
}
export function getTitleByTmdbId(tmdbId: number) {
return db.select().from(titles).where(eq(titles.tmdbId, tmdbId)).get();
}
export function getTitleByTmdbIdAndType(tmdbId: number, type: "movie" | "tv") {
return db
.select()
.from(titles)
.where(and(eq(titles.tmdbId, tmdbId), eq(titles.type, type)))
.get();
}
export function updateTitleFields(titleId: string, values: Partial<typeof titles.$inferInsert>) {
db.update(titles).set(values).where(eq(titles.id, titleId)).run();
}
export function updateTrailerKey(titleId: string, key: string | null) {
db.update(titles).set({ trailerVideoKey: key }).where(eq(titles.id, titleId)).run();
}
// ─── Genres ──────────────────────────────────────────────────────────
interface TmdbGenreInput {
id: number;
name?: string;
}
/**
* Upsert genre rows and re-link them to a title in a single transaction.
* Filters out genres without a name.
*/
export function upsertGenresTransaction(titleId: string, tmdbGenres: TmdbGenreInput[]) {
const validGenres = tmdbGenres.filter((g): g is TmdbGenreInput & { name: string } => !!g.name);
if (validGenres.length === 0) return;
db.transaction((tx) => {
for (const g of validGenres) {
tx.insert(genres)
.values({ id: g.id, name: g.name })
.onConflictDoUpdate({ target: genres.id, set: { name: g.name } })
.run();
}
tx.delete(titleGenres).where(eq(titleGenres.titleId, titleId)).run();
for (const g of validGenres) {
tx.insert(titleGenres).values({ titleId, genreId: g.id }).onConflictDoNothing().run();
}
});
}
export function getTitleGenres(titleId: string) {
return db
.select({ name: genres.name })
.from(titleGenres)
.innerJoin(genres, eq(titleGenres.genreId, genres.id))
.where(eq(titleGenres.titleId, titleId))
.all();
}
// ─── Seasons ─────────────────────────────────────────────────────────
export function hasSeasonForTitle(titleId: string) {
return (
db
.select({ id: seasons.id })
.from(seasons)
.where(eq(seasons.titleId, titleId))
.limit(1)
.get() != null
);
}
export function getExistingSeasonInfo(titleId: string, seasonNumber: number) {
return db
.select({
id: seasons.id,
posterPath: seasons.posterPath,
})
.from(seasons)
.where(and(eq(seasons.titleId, titleId), eq(seasons.seasonNumber, seasonNumber)))
.get();
}
export function upsertSeasonReturning(values: {
titleId: string;
seasonNumber: number;
name: string | null;
overview: string | null;
posterPath: string | null;
airDate: string | null;
lastFetchedAt: Date;
}) {
return db
.insert(seasons)
.values(values)
.onConflictDoUpdate({
target: [seasons.titleId, seasons.seasonNumber],
set: {
name: values.name,
overview: values.overview,
posterPath: values.posterPath,
airDate: values.airDate,
lastFetchedAt: values.lastFetchedAt,
},
})
.returning()
.get();
}
export function getSeasonsForTitle(titleId: string) {
return db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.orderBy(seasons.seasonNumber)
.all();
}
export function nullifySeasonThumbHash(seasonId: string) {
db.update(seasons).set({ posterThumbHash: null }).where(eq(seasons.id, seasonId)).run();
}
// ─── Episodes ────────────────────────────────────────────────────────
export function getOldEpisodeStills(seasonId: string) {
return new Map(
db
.select({
episodeNumber: episodes.episodeNumber,
stillPath: episodes.stillPath,
})
.from(episodes)
.where(eq(episodes.seasonId, seasonId))
.all()
.map((e) => [e.episodeNumber, e.stillPath] as const),
);
}
interface EpisodeUpsertInput {
episode_number: number;
name: string | null;
overview: string | null;
still_path: string | null;
air_date: string | null;
runtime: number | null;
}
/**
* Upsert all episodes for a season in a single transaction.
*/
export function batchUpsertEpisodes(seasonId: string, eps: EpisodeUpsertInput[]) {
if (eps.length === 0) return;
db.transaction((tx) => {
for (const ep of eps) {
tx.insert(episodes)
.values({
seasonId,
episodeNumber: ep.episode_number,
name: ep.name,
overview: ep.overview,
stillPath: ep.still_path,
airDate: ep.air_date,
runtimeMinutes: ep.runtime,
})
.onConflictDoUpdate({
target: [episodes.seasonId, episodes.episodeNumber],
set: {
name: ep.name,
overview: ep.overview,
stillPath: ep.still_path,
airDate: ep.air_date,
runtimeMinutes: ep.runtime,
},
})
.run();
}
});
}
export function getSeasonEpisodesWithHashes(seasonId: string) {
return db
.select({
id: episodes.id,
episodeNumber: episodes.episodeNumber,
stillPath: episodes.stillPath,
stillThumbHash: episodes.stillThumbHash,
})
.from(episodes)
.where(eq(episodes.seasonId, seasonId))
.all();
}
export function nullifyEpisodeThumbHash(episodeId: string) {
db.update(episodes).set({ stillThumbHash: null }).where(eq(episodes.id, episodeId)).run();
}
export function getEpisodesBySeasonIds(seasonIds: string[]) {
if (seasonIds.length === 0) return [];
return db
.select()
.from(episodes)
.where(inArray(episodes.seasonId, seasonIds))
.orderBy(episodes.seasonId, episodes.episodeNumber)
.all();
}
/**
* Select episodes that need a still thumb hash (have a stillPath but no hash).
*/
export function getEpisodesNeedingStillHash(seasonIds: string[]) {
if (seasonIds.length === 0) return [];
return db
.select({
id: episodes.id,
stillPath: episodes.stillPath,
})
.from(episodes)
.where(
and(
inArray(episodes.seasonId, seasonIds),
isNotNull(episodes.stillPath),
sql`${episodes.stillThumbHash} IS NULL`,
),
)
.all();
}
// ─── Availability ────────────────────────────────────────────────────
export function getAvailabilityOffersForTitle(titleId: string) {
return db.select().from(availabilityOffers).where(eq(availabilityOffers.titleId, titleId)).all();
}
// ─── Recommendations ─────────────────────────────────────────────────
export function hasRecommendationsForTitle(titleId: string) {
return (
db
.select({ titleId: titleRecommendations.titleId })
.from(titleRecommendations)
.where(eq(titleRecommendations.titleId, titleId))
.limit(1)
.get() != null
);
}
interface RecommendationTitleInput {
tmdbId: number;
type: "movie" | "tv";
title: string;
originalTitle: string | null;
overview: string | null;
releaseDate: string | null;
firstAirDate: string | null;
posterPath: string | null;
backdropPath: string | null;
popularity: number | null;
voteAverage: number | null;
voteCount: number | null;
}
interface RecommendationRowInput {
tmdbId: number;
source: "tmdb_recommendations" | "tmdb_similar";
rank: number;
}
/**
* Large transaction: upsert shell titles for recommendations, then upsert
* the recommendation link rows. Returns the titleIdMap (tmdbId -> titleId)
* so the caller can fire off thumbhash generation.
*/
export function upsertRecommendationsTransaction(
titleId: string,
uniqueTitles: Map<number, RecommendationTitleInput>,
allItems: RecommendationRowInput[],
now: Date,
): Map<number, string> {
const tmdbIds = [...uniqueTitles.keys()];
// Batch prefetch existing titles (1 query)
const existingTitles = db
.select({
id: titles.id,
tmdbId: titles.tmdbId,
posterPath: titles.posterPath,
backdropPath: titles.backdropPath,
posterThumbHash: titles.posterThumbHash,
backdropThumbHash: titles.backdropThumbHash,
})
.from(titles)
.where(inArray(titles.tmdbId, tmdbIds))
.all();
const existingTitleMap = new Map(existingTitles.map((t) => [t.tmdbId, t]));
const titleIdMap = new Map<number, string>(existingTitles.map((t) => [t.tmdbId, t.id]));
db.transaction((tx) => {
const insertedTmdbIds = new Set<number>();
for (const item of uniqueTitles.values()) {
const existingTitle = existingTitleMap.get(item.tmdbId);
if (existingTitle) {
const posterPathChanged = existingTitle.posterPath !== item.posterPath;
const backdropPathChanged = existingTitle.backdropPath !== item.backdropPath;
tx.update(titles)
.set({
type: item.type,
title: item.title,
originalTitle: item.originalTitle,
overview: item.overview,
releaseDate: item.releaseDate,
firstAirDate: item.firstAirDate,
posterPath: item.posterPath,
backdropPath: item.backdropPath,
popularity: item.popularity,
voteAverage: item.voteAverage,
voteCount: item.voteCount,
...(posterPathChanged
? {
posterThumbHash: null,
colorPalette: null,
}
: {}),
...(backdropPathChanged
? {
backdropThumbHash: null,
}
: {}),
})
.where(eq(titles.id, existingTitle.id))
.run();
continue;
}
insertedTmdbIds.add(item.tmdbId);
const row = tx
.insert(titles)
.values({
tmdbId: item.tmdbId,
type: item.type,
title: item.title,
originalTitle: item.originalTitle,
overview: item.overview,
releaseDate: item.releaseDate,
firstAirDate: item.firstAirDate,
posterPath: item.posterPath,
backdropPath: item.backdropPath,
popularity: item.popularity,
voteAverage: item.voteAverage,
voteCount: item.voteCount,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning()
.get();
if (row) titleIdMap.set(item.tmdbId, row.id);
}
// One fallback query for any that conflicted
const stillMissing = [...insertedTmdbIds].filter((id) => !titleIdMap.has(id));
if (stillMissing.length > 0) {
const fallbacks = tx
.select({ id: titles.id, tmdbId: titles.tmdbId })
.from(titles)
.where(inArray(titles.tmdbId, stillMissing))
.all();
for (const f of fallbacks) titleIdMap.set(f.tmdbId, f.id);
}
// Batch upsert all recommendation rows
const recRows = allItems
.map((item) => {
const recTitleId = titleIdMap.get(item.tmdbId);
if (!recTitleId) return null;
return {
titleId,
recommendedTitleId: recTitleId,
source: item.source,
rank: item.rank,
lastFetchedAt: now,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
if (recRows.length > 0) {
tx.insert(titleRecommendations)
.values(recRows)
.onConflictDoUpdate({
target: [
titleRecommendations.titleId,
titleRecommendations.recommendedTitleId,
titleRecommendations.source,
],
set: {
rank: sql`excluded.rank`,
lastFetchedAt: sql`excluded.lastFetchedAt`,
},
})
.run();
}
});
return titleIdMap;
}
// ─── Poster hash helpers ─────────────────────────────────────────────
export function getTitlesNeedingPosterHash(titleIds: string[]) {
if (titleIds.length === 0) return [];
return db
.select({ id: titles.id, posterPath: titles.posterPath })
.from(titles)
.where(
and(
inArray(titles.id, titleIds),
isNotNull(titles.posterPath),
sql`${titles.posterThumbHash} IS NULL`,
),
)
.all();
}
// ─── Browse batch upsert ─────────────────────────────────────────────
interface BrowseTitleInput {
tmdbId: number;
type: "movie" | "tv";
title: string;
posterPath: string | null;
backdropPath?: string | null;
releaseDate?: string | null;
firstAirDate?: string | null;
overview?: string | null;
popularity?: number | null;
voteAverage?: number | null;
voteCount?: number | null;
}
/**
* Ensure every browse/search result has a local title row.
* Inserts shell titles (`lastFetchedAt = null`) for new (tmdbId, type) pairs.
* Returns a map keyed by `${tmdbId}-${type}` -> `{ id, posterThumbHash }`.
*/
export function ensureBrowseTitlesTransaction(
items: BrowseTitleInput[],
): Map<string, { id: string; posterThumbHash: string | null }> {
if (items.length === 0) return new Map();
// Deduplicate by (tmdbId, type)
const unique = new Map<string, BrowseTitleInput>();
for (const item of items) {
const key = `${item.tmdbId}-${item.type}`;
if (!unique.has(key)) unique.set(key, item);
}
const tmdbIds = [...new Set(items.map((i) => i.tmdbId))];
// Batch-fetch existing titles (1 query)
const existing = db
.select({
id: titles.id,
tmdbId: titles.tmdbId,
type: titles.type,
posterThumbHash: titles.posterThumbHash,
})
.from(titles)
.where(inArray(titles.tmdbId, tmdbIds))
.all();
const result = new Map<string, { id: string; posterThumbHash: string | null }>();
for (const row of existing) {
result.set(`${row.tmdbId}-${row.type}`, {
id: row.id,
posterThumbHash: row.posterThumbHash,
});
}
// Find items that need inserting
const missingKeys = [...unique.keys()].filter((key) => !result.has(key));
if (missingKeys.length === 0) return result;
// Insert missing in a single transaction
db.transaction((tx) => {
for (const key of missingKeys) {
const item = unique.get(key);
if (!item) continue;
const row = tx
.insert(titles)
.values({
tmdbId: item.tmdbId,
type: item.type,
title: item.title,
overview: item.overview ?? null,
releaseDate: item.releaseDate ?? null,
firstAirDate: item.firstAirDate ?? null,
posterPath: item.posterPath,
backdropPath: item.backdropPath ?? null,
popularity: item.popularity ?? null,
voteAverage: item.voteAverage ?? null,
voteCount: item.voteCount ?? null,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning({ id: titles.id, posterThumbHash: titles.posterThumbHash })
.get();
if (row) {
result.set(key, {
id: row.id,
posterThumbHash: row.posterThumbHash,
});
}
}
// Fallback for any that conflicted (concurrent insert)
const stillMissing = missingKeys.filter((key) => !result.has(key));
if (stillMissing.length > 0) {
const missingTmdbIds = stillMissing.map((key) => unique.get(key)?.tmdbId ?? 0);
const fallbacks = tx
.select({
id: titles.id,
tmdbId: titles.tmdbId,
type: titles.type,
posterThumbHash: titles.posterThumbHash,
})
.from(titles)
.where(inArray(titles.tmdbId, missingTmdbIds))
.all();
for (const f of fallbacks) {
result.set(`${f.tmdbId}-${f.type}`, {
id: f.id,
posterThumbHash: f.posterThumbHash,
});
}
}
});
return result;
}
+223
View File
@@ -0,0 +1,223 @@
import { eq, inArray } from "drizzle-orm";
import { db } from "../client";
import { personFilmography, persons, titles } from "../schema";
// ─── Single-row lookups ──────────────────────────────────────────────
export function getPersonById(personId: string) {
return db.select().from(persons).where(eq(persons.id, personId)).get();
}
export function getPersonByTmdbId(tmdbId: number) {
return db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get();
}
// ─── Mutations ───────────────────────────────────────────────────────
export function updatePerson(personId: string, values: Partial<typeof persons.$inferInsert>) {
db.update(persons).set(values).where(eq(persons.id, personId)).run();
}
export function insertPersonReturning(values: typeof persons.$inferInsert) {
return db.insert(persons).values(values).onConflictDoNothing().returning().get();
}
// ─── Filmography (joined read) ───────────────────────────────────────
export function getPersonFilmographyJoined(personId: string) {
return db
.select({
titleId: titles.id,
tmdbId: titles.tmdbId,
type: titles.type,
title: titles.title,
posterPath: titles.posterPath,
posterThumbHash: titles.posterThumbHash,
releaseDate: titles.releaseDate,
firstAirDate: titles.firstAirDate,
voteAverage: titles.voteAverage,
character: personFilmography.character,
department: personFilmography.department,
job: personFilmography.job,
})
.from(personFilmography)
.innerJoin(titles, eq(personFilmography.titleId, titles.id))
.where(eq(personFilmography.personId, personId))
.orderBy(personFilmography.displayOrder)
.all();
}
// ─── Batch shell-title insert (transaction) ──────────────────────────
interface ShellTitleEntry {
tmdbId: number;
mediaType: "movie" | "tv";
title: string;
overview?: string | null;
releaseDate?: string | null;
firstAirDate?: string | null;
posterPath?: string | null;
backdropPath?: string | null;
popularity?: number | null;
voteAverage?: number | null;
voteCount?: number | null;
}
/**
* Insert shell title rows inside a transaction, returning a map of tmdbId to
* internal UUID for every entry that was inserted (or already existed via
* conflict fallback).
*/
export function batchInsertShellTitlesTransaction(
entries: ShellTitleEntry[],
existingTitleIdMap: Map<number, string>,
): Map<number, string> {
const titleIdMap = new Map(existingTitleIdMap);
const insertedTmdbIds = new Set<number>();
db.transaction((tx) => {
for (const entry of entries) {
if (insertedTmdbIds.has(entry.tmdbId)) continue;
insertedTmdbIds.add(entry.tmdbId);
const row = tx
.insert(titles)
.values({
tmdbId: entry.tmdbId,
type: entry.mediaType,
title: entry.title,
overview: entry.overview,
releaseDate: entry.releaseDate,
firstAirDate: entry.firstAirDate,
posterPath: entry.posterPath,
backdropPath: entry.backdropPath,
popularity: entry.popularity,
voteAverage: entry.voteAverage,
voteCount: entry.voteCount,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning()
.get();
if (row) titleIdMap.set(entry.tmdbId, row.id);
}
});
// Resolve any rows that hit onConflictDoNothing (already existed but weren't
// in the initial map)
const stillMissing = [...insertedTmdbIds].filter((tmdbId) => !titleIdMap.has(tmdbId));
if (stillMissing.length > 0) {
const fallbacks = db
.select({ id: titles.id, tmdbId: titles.tmdbId })
.from(titles)
.where(inArray(titles.tmdbId, stillMissing))
.all();
for (const fallback of fallbacks) {
titleIdMap.set(fallback.tmdbId, fallback.id);
}
}
return titleIdMap;
}
// ─── Replace filmography (transaction) ───────────────────────────────
/**
* Atomically delete all filmography rows for a person and re-insert the new
* set, then stamp `filmographyLastFetchedAt`.
*/
export function replaceFilmographyTransaction(
personId: string,
rows: (typeof personFilmography.$inferInsert)[],
fetchedAt: Date,
): void {
db.transaction((tx) => {
tx.delete(personFilmography).where(eq(personFilmography.personId, personId)).run();
for (const row of rows) {
tx.insert(personFilmography).values(row).run();
}
tx.update(persons)
.set({ filmographyLastFetchedAt: fetchedAt })
.where(eq(persons.id, personId))
.run();
});
}
// ─── Browse batch upsert (transaction) ───────────────────────────────
interface BrowsePersonInput {
tmdbId: number;
name: string;
profilePath: string | null;
knownForDepartment?: string | null;
popularity?: number | null;
}
/**
* Ensure every person in the batch has a local row. Inserts shell persons
* (`lastFetchedAt = null`) for new tmdbIds.
* Returns a map of tmdbId to internal UUID.
*/
export function ensureBrowsePersonsTransaction(items: BrowsePersonInput[]): Map<number, string> {
if (items.length === 0) return new Map();
const unique = new Map<number, BrowsePersonInput>();
for (const item of items) {
if (!unique.has(item.tmdbId)) unique.set(item.tmdbId, item);
}
const tmdbIds = [...unique.keys()];
const existing = db
.select({ id: persons.id, tmdbId: persons.tmdbId })
.from(persons)
.where(inArray(persons.tmdbId, tmdbIds))
.all();
const result = new Map<number, string>();
for (const row of existing) {
result.set(row.tmdbId, row.id);
}
const missing = tmdbIds.filter((id) => !result.has(id));
if (missing.length === 0) return result;
db.transaction((tx) => {
for (const tmdbId of missing) {
const item = unique.get(tmdbId);
if (!item) continue;
const row = tx
.insert(persons)
.values({
tmdbId: item.tmdbId,
name: item.name,
profilePath: item.profilePath,
knownForDepartment: item.knownForDepartment ?? null,
popularity: item.popularity ?? null,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning({ id: persons.id })
.get();
if (row) {
result.set(tmdbId, row.id);
}
}
const stillMissing = missing.filter((id) => !result.has(id));
if (stillMissing.length > 0) {
const fallbacks = tx
.select({ id: persons.id, tmdbId: persons.tmdbId })
.from(persons)
.where(inArray(persons.tmdbId, stillMissing))
.all();
for (const f of fallbacks) {
result.set(f.tmdbId, f.id);
}
}
});
return result;
}
+21
View File
@@ -0,0 +1,21 @@
import { count, eq } from "drizzle-orm";
import { db } from "../client";
import { appSettings, user } from "../schema";
export function getSettingValue(key: string): string | null {
const row = db.select().from(appSettings).where(eq(appSettings.key, key)).get();
return row?.value ?? null;
}
export function upsertSetting(key: string, value: string): void {
db.insert(appSettings)
.values({ key, value })
.onConflictDoUpdate({ target: appSettings.key, set: { value } })
.run();
}
export function getUserCount(): number {
const result = db.select({ count: count() }).from(user).get();
return result?.count ?? 0;
}
+25
View File
@@ -0,0 +1,25 @@
import { count, desc, eq } from "drizzle-orm";
import { db } from "../client";
import { cronRuns, episodes, titles, user } from "../schema";
export function getTableCounts() {
const [titleCount] = db.select({ count: count() }).from(titles).all();
const [episodeCount] = db.select({ count: count() }).from(episodes).all();
const [userCount] = db.select({ count: count() }).from(user).all();
return {
titleCount: titleCount.count,
episodeCount: episodeCount.count,
userCount: userCount.count,
};
}
export function getLatestCronRun(jobName: string) {
return db
.select()
.from(cronRuns)
.where(eq(cronRuns.jobName, jobName))
.orderBy(desc(cronRuns.startedAt))
.limit(1)
.get();
}
+24
View File
@@ -0,0 +1,24 @@
import { eq } from "drizzle-orm";
import { db } from "../client";
import { episodes, persons, seasons, titles } from "../schema";
export function updateTitlePosterThumbHash(titleId: string, hash: string | null): void {
db.update(titles).set({ posterThumbHash: hash }).where(eq(titles.id, titleId)).run();
}
export function updateTitleBackdropThumbHash(titleId: string, hash: string | null): void {
db.update(titles).set({ backdropThumbHash: hash }).where(eq(titles.id, titleId)).run();
}
export function updateSeasonPosterThumbHash(seasonId: string, hash: string | null): void {
db.update(seasons).set({ posterThumbHash: hash }).where(eq(seasons.id, seasonId)).run();
}
export function updateEpisodeStillThumbHash(episodeId: string, hash: string | null): void {
db.update(episodes).set({ stillThumbHash: hash }).where(eq(episodes.id, episodeId)).run();
}
export function updatePersonProfileThumbHash(personId: string, hash: string | null): void {
db.update(persons).set({ profileThumbHash: hash }).where(eq(persons.id, personId)).run();
}
+34
View File
@@ -0,0 +1,34 @@
import { and, count, eq, inArray } from "drizzle-orm";
import { db } from "../client";
import { episodes, seasons, titles } from "../schema";
export function getTitleById(titleId: string) {
return db.select().from(titles).where(eq(titles.id, titleId)).get();
}
export function getExistingTitlesByTmdbIds(tmdbIds: number[]) {
if (tmdbIds.length === 0) return [];
return db.select().from(titles).where(inArray(titles.tmdbId, tmdbIds)).all();
}
export function findSeasonByTitleAndNumber(titleId: string, seasonNumber: number) {
return db
.select()
.from(seasons)
.where(and(eq(seasons.titleId, titleId), eq(seasons.seasonNumber, seasonNumber)))
.get();
}
export function findEpisodeBySeasonAndNumber(seasonId: string, episodeNumber: number) {
return db
.select()
.from(episodes)
.where(and(eq(episodes.seasonId, seasonId), eq(episodes.episodeNumber, episodeNumber)))
.get();
}
export function getTitleCount(): number {
const result = db.select({ count: count() }).from(titles).get();
return result?.count ?? 0;
}
+346
View File
@@ -0,0 +1,346 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { db } from "../client";
import {
episodes,
seasons,
titles,
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "../schema";
export function upsertTitleStatus(
userId: string,
titleId: string,
status: "watchlist" | "in_progress" | "completed",
addedAt?: Date,
): void {
const now = addedAt ?? new Date();
db.insert(userTitleStatus)
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
.onConflictDoUpdate({
target: [userTitleStatus.userId, userTitleStatus.titleId],
set: { status, updatedAt: now },
})
.run();
}
export function deleteTitleStatus(userId: string, titleId: string): void {
db.delete(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
.run();
}
export function insertMovieWatch(
userId: string,
titleId: string,
watchedAt: Date,
source: "manual" | "import" | "plex" | "jellyfin" | "emby",
): void {
db.insert(userMovieWatches).values({ userId, titleId, watchedAt, source }).run();
}
export function insertEpisodeWatch(
userId: string,
episodeId: string,
watchedAt: Date,
source: "manual" | "import" | "plex" | "jellyfin" | "emby",
): void {
db.insert(userEpisodeWatches).values({ userId, episodeId, watchedAt, source }).run();
}
export function getTitleStatus(userId: string, titleId: string) {
return db
.select()
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
.get();
}
export function getEpisodeTitleId(episodeId: string): string | null {
const row = db
.select({ titleId: seasons.titleId })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(eq(episodes.id, episodeId))
.get();
return row?.titleId ?? null;
}
export function batchInsertEpisodeWatchesTransaction(
userId: string,
episodeIds: string[],
source: "manual" | "import" | "plex" | "jellyfin" | "emby",
watchedAt?: Date,
): { titleId: string | null; completionReached: boolean } {
if (episodeIds.length === 0) return { titleId: null, completionReached: false };
let resultTitleId: string | null = null;
let completionReached = false;
db.transaction((tx) => {
const now = watchedAt ?? new Date();
for (const episodeId of episodeIds) {
tx.insert(userEpisodeWatches).values({ userId, episodeId, watchedAt: now, source }).run();
}
const eps = tx.select().from(episodes).where(inArray(episodes.id, episodeIds)).all();
if (eps.length === 0) return;
const seasonIds = [...new Set(eps.map((e) => e.seasonId))];
const seasonRows = tx.select().from(seasons).where(inArray(seasons.id, seasonIds)).all();
if (seasonRows.length === 0) return;
const { titleId } = seasonRows[0];
resultTitleId = titleId;
const existing = tx
.select()
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
.get();
if (!existing || existing.status === "watchlist") {
const statusNow = new Date();
tx.insert(userTitleStatus)
.values({
userId,
titleId,
status: "in_progress",
addedAt: statusNow,
updatedAt: statusNow,
})
.onConflictDoUpdate({
target: [userTitleStatus.userId, userTitleStatus.titleId],
set: { status: "in_progress", updatedAt: statusNow },
})
.run();
}
const allSeasons = tx.select().from(seasons).where(eq(seasons.titleId, titleId)).all();
if (allSeasons.length === 0) return;
const allSeasonIds = allSeasons.map((s) => s.id);
const allEps = tx.select().from(episodes).where(inArray(episodes.seasonId, allSeasonIds)).all();
const totalEpisodes = allEps.length;
if (totalEpisodes === 0) return;
const allEpIds = allEps.map((ep) => ep.id);
const [watchCount] = tx
.select({
count: sql<number>`count(distinct ${userEpisodeWatches.episodeId})`,
})
.from(userEpisodeWatches)
.where(
and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, allEpIds)),
)
.all();
if (watchCount.count >= totalEpisodes) {
completionReached = true;
const completeNow = new Date();
tx.insert(userTitleStatus)
.values({
userId,
titleId,
status: "completed",
addedAt: completeNow,
updatedAt: completeNow,
})
.onConflictDoUpdate({
target: [userTitleStatus.userId, userTitleStatus.titleId],
set: { status: "completed", updatedAt: completeNow },
})
.run();
}
});
return { titleId: resultTitleId, completionReached };
}
export function getAllEpisodeIdsForTitle(titleId: string): string[] {
return db
.select({ id: episodes.id })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(eq(seasons.titleId, titleId))
.all()
.map((ep) => ep.id);
}
export function getExistingEpisodeWatchIds(userId: string, episodeIds: string[]): Set<string> {
if (episodeIds.length === 0) return new Set();
return new Set(
db
.select({ episodeId: userEpisodeWatches.episodeId })
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
inArray(userEpisodeWatches.episodeId, episodeIds),
),
)
.all()
.map((w) => w.episodeId),
);
}
export function batchInsertMissingEpisodeWatches(
userId: string,
episodeIds: string[],
existingWatches: Set<string>,
source: "manual" | "import" | "plex" | "jellyfin" | "emby",
watchedAt: Date,
): void {
db.transaction((tx) => {
for (const episodeId of episodeIds) {
if (!existingWatches.has(episodeId)) {
tx.insert(userEpisodeWatches).values({ userId, episodeId, watchedAt, source }).run();
}
}
});
}
export function countDistinctEpisodeWatches(userId: string, episodeIds: string[]): number {
if (episodeIds.length === 0) return 0;
const [result] = db
.select({
count: sql<number>`count(distinct ${userEpisodeWatches.episodeId})`,
})
.from(userEpisodeWatches)
.where(
and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, episodeIds)),
)
.all();
return result.count;
}
export function upsertRating(
userId: string,
titleId: string,
ratingStars: number,
ratedAt: Date,
): void {
db.insert(userRatings)
.values({ userId, titleId, ratingStars, ratedAt })
.onConflictDoUpdate({
target: [userRatings.userId, userRatings.titleId],
set: { ratingStars, ratedAt },
})
.run();
}
export function deleteRating(userId: string, titleId: string): void {
db.delete(userRatings)
.where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)))
.run();
}
export function getUserStatusesByTitleIds(userId: string, titleIds: string[]) {
if (titleIds.length === 0) return [];
return db
.select({
titleId: userTitleStatus.titleId,
status: userTitleStatus.status,
})
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), inArray(userTitleStatus.titleId, titleIds)))
.all();
}
export function getEpisodeProgressByTitleIds(userId: string, titleIds: string[]) {
if (titleIds.length === 0) return [];
return db
.select({
titleId: titles.id,
totalEpisodes: sql<number>`count(distinct ${episodes.id})`.as("totalEpisodes"),
watchedEpisodes:
sql<number>`count(distinct case when ${userEpisodeWatches.id} is not null then ${episodes.id} end)`.as(
"watchedEpisodes",
),
})
.from(titles)
.innerJoin(seasons, eq(seasons.titleId, titles.id))
.innerJoin(episodes, eq(episodes.seasonId, seasons.id))
.leftJoin(
userEpisodeWatches,
and(eq(userEpisodeWatches.episodeId, episodes.id), eq(userEpisodeWatches.userId, userId)),
)
.where(and(inArray(titles.id, titleIds), eq(titles.type, "tv")))
.groupBy(titles.id)
.all();
}
export function getUserTitleInfo(userId: string, titleId: string) {
const status = db
.select()
.from(userTitleStatus)
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
.get();
const rating = db
.select()
.from(userRatings)
.where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)))
.get();
const allEps = db
.select({ id: episodes.id })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(eq(seasons.titleId, titleId))
.all();
const epIds = allEps.map((ep) => ep.id);
const watchedEpisodeIds =
epIds.length > 0
? Array.from(
new Set(
db
.select({ episodeId: userEpisodeWatches.episodeId })
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
inArray(userEpisodeWatches.episodeId, epIds),
),
)
.all()
.map((w) => w.episodeId),
),
)
: [];
return {
status: status?.status ?? null,
rating: rating?.ratingStars ?? null,
episodeWatches: watchedEpisodeIds,
};
}
export function getSeasonEpisodes(seasonId: string) {
return db.select().from(episodes).where(eq(episodes.seasonId, seasonId)).all();
}
export function getSeasonById(seasonId: string) {
return db.select().from(seasons).where(eq(seasons.id, seasonId)).get();
}
export function deleteEpisodeWatches(userId: string, episodeIds: string[]): void {
if (episodeIds.length === 0) return;
db.delete(userEpisodeWatches)
.where(
and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, episodeIds)),
)
.run();
}
export function deleteEpisodeWatch(userId: string, episodeId: string): void {
db.delete(userEpisodeWatches)
.where(and(eq(userEpisodeWatches.userId, userId), eq(userEpisodeWatches.episodeId, episodeId)))
.run();
}
+43
View File
@@ -0,0 +1,43 @@
import { and, eq, gte } from "drizzle-orm";
import { db } from "../client";
import { integrationEvents, integrations, userEpisodeWatches, userMovieWatches } from "../schema";
export function getRecentMovieWatch(userId: string, titleId: string, since: Date) {
return db
.select()
.from(userMovieWatches)
.where(
and(
eq(userMovieWatches.userId, userId),
eq(userMovieWatches.titleId, titleId),
gte(userMovieWatches.watchedAt, since),
),
)
.get();
}
export function getRecentEpisodeWatch(userId: string, episodeId: string, since: Date) {
return db
.select()
.from(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
eq(userEpisodeWatches.episodeId, episodeId),
gte(userEpisodeWatches.watchedAt, since),
),
)
.get();
}
export function insertIntegrationEvent(values: typeof integrationEvents.$inferInsert): void {
db.insert(integrationEvents).values(values).run();
}
export function updateIntegrationLastEvent(connectionId: string): void {
db.update(integrations)
.set({ lastEventAt: new Date() })
.where(eq(integrations.id, connectionId))
.run();
}
+15
View File
@@ -195,3 +195,18 @@ export function insertRecommendation(
})
.run();
}
export {
and,
count,
desc,
eq,
gte,
inArray,
isNotNull,
isNull,
lt,
notInArray,
or,
sql,
} from "drizzle-orm";
+7
View File
@@ -0,0 +1,7 @@
import { sql } from "drizzle-orm";
import { db } from "./client";
export function vacuumInto(destPath: string): void {
db.run(sql.raw(`VACUUM INTO '${destPath.replace(/'/g, "''")}'`));
}