mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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:
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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
@@ -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") }),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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");
|
||||
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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,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,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,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==",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
export {
|
||||
and,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
lt,
|
||||
notInArray,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -195,3 +195,18 @@ export function insertRecommendation(
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
export {
|
||||
and,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
lt,
|
||||
notInArray,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
@@ -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, "''")}'`));
|
||||
}
|
||||
Reference in New Issue
Block a user