mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -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)
|
│ ├── api/ # @sofa/api — oRPC contract + Zod schemas (shared, JIT)
|
||||||
│ ├── auth/ # @sofa/auth — Better Auth server config (JIT)
|
│ ├── auth/ # @sofa/auth — Better Auth server config (JIT)
|
||||||
│ ├── config/ # @sofa/config — Path constants + TMDB URLs from env (JIT)
|
│ ├── config/ # @sofa/config — Path constants + TMDB URLs from env (JIT)
|
||||||
│ ├── core/ # @sofa/core — Business logic services (JIT)
|
│ ├── core/ # @sofa/core — Business logic services (JIT, DB-agnostic)
|
||||||
│ ├── db/ # @sofa/db — Drizzle schema, client, migrations (JIT)
|
│ ├── db/ # @sofa/db — Drizzle schema, client, queries, migrations (JIT)
|
||||||
│ ├── logger/ # @sofa/logger — Pino-based structured logging (JIT)
|
│ ├── logger/ # @sofa/logger — Pino-based structured logging (JIT)
|
||||||
│ └── tmdb/ # @sofa/tmdb — TMDB API client + image URL helper (JIT)
|
│ └── tmdb/ # @sofa/tmdb — TMDB API client + image URL helper (JIT)
|
||||||
├── .oxlintrc.json
|
├── .oxlintrc.json
|
||||||
@@ -87,7 +87,8 @@ Path aliases: `@/*` maps to `src/` in both `apps/web/` and `apps/native/`.
|
|||||||
Cross-package imports:
|
Cross-package imports:
|
||||||
|
|
||||||
- `@sofa/api/contract`, `@sofa/api/schemas` — Contract and Zod types
|
- `@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/tmdb/client`, `@sofa/tmdb/image`
|
||||||
- `@sofa/core/metadata`, `@sofa/core/tracking`, `@sofa/core/imports`, etc.
|
- `@sofa/core/metadata`, `@sofa/core/tracking`, `@sofa/core/imports`, etc.
|
||||||
- `@sofa/auth/server`, `@sofa/auth/config`
|
- `@sofa/auth/server`, `@sofa/auth/config`
|
||||||
@@ -96,6 +97,10 @@ Cross-package imports:
|
|||||||
|
|
||||||
### Key patterns
|
### 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()`.
|
**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()`.
|
**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 { refreshAvailability } from "@sofa/core/availability";
|
||||||
import { createBackup, ensureBackupDir, pruneBackups } from "@sofa/core/backup";
|
import { createBackup, ensureBackupDir, pruneBackups } from "@sofa/core/backup";
|
||||||
import { refreshCredits, syncCastProfileThumbHashes } from "@sofa/core/credits";
|
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 {
|
import {
|
||||||
cacheEpisodeStills,
|
cacheEpisodeStills,
|
||||||
cacheImagesForTitle,
|
cacheImagesForTitle,
|
||||||
@@ -20,18 +34,6 @@ import { getSetting } from "@sofa/core/settings";
|
|||||||
import { performTelemetryReport } from "@sofa/core/telemetry";
|
import { performTelemetryReport } from "@sofa/core/telemetry";
|
||||||
import { generateTitleBackdropThumbHash, generateTitlePosterThumbHash } from "@sofa/core/thumbhash";
|
import { generateTitleBackdropThumbHash, generateTitlePosterThumbHash } from "@sofa/core/thumbhash";
|
||||||
import { performUpdateCheck } from "@sofa/core/update-check";
|
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 { createLogger } from "@sofa/logger";
|
||||||
import { getTvDetails } from "@sofa/tmdb/client";
|
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 () => {
|
new Cron(cron, { name, protect: true }, async () => {
|
||||||
log.info(`Running job: ${name}`);
|
log.info(`Running job: ${name}`);
|
||||||
const startMs = performance.now();
|
const startMs = performance.now();
|
||||||
const run = db
|
const run = startCronRun(name);
|
||||||
.insert(cronRuns)
|
|
||||||
.values({ jobName: name, status: "running", startedAt: new Date() })
|
|
||||||
.returning()
|
|
||||||
.get();
|
|
||||||
try {
|
try {
|
||||||
await handler();
|
await handler();
|
||||||
const durationMs = Math.round(performance.now() - startMs);
|
const durationMs = Math.round(performance.now() - startMs);
|
||||||
db.update(cronRuns)
|
completeCronRun(run.id, durationMs);
|
||||||
.set({ status: "success", finishedAt: new Date(), durationMs })
|
|
||||||
.where(eq(cronRuns.id, run.id))
|
|
||||||
.run();
|
|
||||||
log.info(`Completed job: ${name} (${durationMs}ms)`);
|
log.info(`Completed job: ${name} (${durationMs}ms)`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const durationMs = Math.round(performance.now() - startMs);
|
const durationMs = Math.round(performance.now() - startMs);
|
||||||
db.update(cronRuns)
|
failCronRun(run.id, durationMs, err);
|
||||||
.set({
|
|
||||||
status: "error",
|
|
||||||
finishedAt: new Date(),
|
|
||||||
durationMs,
|
|
||||||
errorMessage: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
.where(eq(cronRuns.id, run.id))
|
|
||||||
.run();
|
|
||||||
log.error(`Job ${name} failed:`, err);
|
log.error(`Job ${name} failed:`, err);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -108,71 +95,6 @@ export async function triggerJob(name: string): Promise<boolean> {
|
|||||||
return true;
|
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
|
// Refresh titles where lastFetchedAt is stale
|
||||||
async function nightlyRefreshLibrary() {
|
async function nightlyRefreshLibrary() {
|
||||||
const libraryIds = getLibraryTitleIds();
|
const libraryIds = getLibraryTitleIds();
|
||||||
@@ -181,11 +103,7 @@ async function nightlyRefreshLibrary() {
|
|||||||
const nonLibraryStale = new Date(Date.now() - 30 * DAY);
|
const nonLibraryStale = new Date(Date.now() - 30 * DAY);
|
||||||
|
|
||||||
// Library titles: 7 days
|
// Library titles: 7 days
|
||||||
const staleLibrary = db
|
const staleLibrary = getStaleLibraryTitles(libraryIds, libraryStale);
|
||||||
.select({ id: titles.id })
|
|
||||||
.from(titles)
|
|
||||||
.where(and(inArray(titles.id, libraryIds), lt(titles.lastFetchedAt, libraryStale)))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
for (const { id } of staleLibrary) {
|
for (const { id } of staleLibrary) {
|
||||||
await refreshTitle(id);
|
await refreshTitle(id);
|
||||||
@@ -193,12 +111,7 @@ async function nightlyRefreshLibrary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Non-library titles: 30 days
|
// Non-library titles: 30 days
|
||||||
const nonLibrary = db
|
const nonLibrary = getStaleNonLibraryTitlesForRefresh(nonLibraryStale, 50);
|
||||||
.select()
|
|
||||||
.from(titles)
|
|
||||||
.where(and(isNotNull(titles.lastFetchedAt), lt(titles.lastFetchedAt, nonLibraryStale)))
|
|
||||||
.limit(50)
|
|
||||||
.all();
|
|
||||||
|
|
||||||
for (const t of nonLibrary) {
|
for (const t of nonLibrary) {
|
||||||
if (!libraryIds.includes(t.id)) {
|
if (!libraryIds.includes(t.id)) {
|
||||||
@@ -214,33 +127,10 @@ async function refreshAvailabilityJob() {
|
|||||||
log.debug(`Checking availability for ${libraryIds.length} library titles`);
|
log.debug(`Checking availability for ${libraryIds.length} library titles`);
|
||||||
const stale = new Date(Date.now() - DAY);
|
const stale = new Date(Date.now() - DAY);
|
||||||
|
|
||||||
const titlesWithOffers = new Set(
|
const { withOffers, withStaleOffers } = getStaleAvailabilityTitles(libraryIds, stale);
|
||||||
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),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const titleId of libraryIds) {
|
for (const titleId of libraryIds) {
|
||||||
if (titlesWithStaleOffers.has(titleId) || !titlesWithOffers.has(titleId)) {
|
if (withStaleOffers.has(titleId) || !withOffers.has(titleId)) {
|
||||||
await refreshAvailability(titleId);
|
await refreshAvailability(titleId);
|
||||||
await Bun.sleep(RATE_LIMIT_MS);
|
await Bun.sleep(RATE_LIMIT_MS);
|
||||||
}
|
}
|
||||||
@@ -258,35 +148,14 @@ async function refreshRecommendationsJob() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshTvChildrenJob() {
|
async function refreshTvChildrenJob() {
|
||||||
const returningStatuses = ["Returning Series", "In Production"];
|
|
||||||
const stale = new Date(Date.now() - 7 * DAY);
|
const stale = new Date(Date.now() - 7 * DAY);
|
||||||
|
|
||||||
const tvShows = db
|
const tvShows = getReturningTvShows();
|
||||||
.select()
|
|
||||||
.from(titles)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(titles.type, "tv"),
|
|
||||||
isNotNull(titles.lastFetchedAt),
|
|
||||||
or(...returningStatuses.map((s) => eq(titles.status, s))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.all();
|
|
||||||
|
|
||||||
log.debug(`Checking ${tvShows.length} returning TV shows for stale episodes`);
|
log.debug(`Checking ${tvShows.length} returning TV shows for stale episodes`);
|
||||||
|
|
||||||
const tvIds = tvShows.map((s) => s.id);
|
const tvIds = tvShows.map((s) => s.id);
|
||||||
const titlesWithStaleSeasons = new Set(
|
const titlesWithStaleSeasons = getTitleIdsWithStaleSeasons(tvIds, stale);
|
||||||
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)
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const show of tvShows) {
|
for (const show of tvShows) {
|
||||||
if (titlesWithStaleSeasons.has(show.id)) {
|
if (titlesWithStaleSeasons.has(show.id)) {
|
||||||
@@ -304,7 +173,7 @@ async function cacheImagesJob() {
|
|||||||
|
|
||||||
for (const titleId of titleIds) {
|
for (const titleId of titleIds) {
|
||||||
try {
|
try {
|
||||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
const title = getTitleByIdForCron(titleId);
|
||||||
if (!title) continue;
|
if (!title) continue;
|
||||||
|
|
||||||
// Phase 1: warm the image cache so thumbhash generation can read from disk
|
// 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);
|
const stale = new Date(Date.now() - 30 * DAY);
|
||||||
|
|
||||||
for (const titleId of libraryIds) {
|
for (const titleId of libraryIds) {
|
||||||
const castEntry = db
|
const castEntry = getCastEntryForTitle(titleId);
|
||||||
.select()
|
|
||||||
.from(titleCast)
|
|
||||||
.where(eq(titleCast.titleId, titleId))
|
|
||||||
.limit(1)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
const needsRefresh = !castEntry || (castEntry.lastFetchedAt && castEntry.lastFetchedAt < stale);
|
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 type { ParseResult } from "@sofa/core/imports";
|
||||||
import {
|
import {
|
||||||
countUnresolved,
|
countUnresolved,
|
||||||
|
getActiveImportJobForUser,
|
||||||
|
insertImportJob,
|
||||||
parseLetterboxdExport,
|
parseLetterboxdExport,
|
||||||
parseSimklPayload,
|
parseSimklPayload,
|
||||||
parseTraktPayload,
|
parseTraktPayload,
|
||||||
processImportJob,
|
processImportJob,
|
||||||
readImportJob,
|
readImportJob,
|
||||||
|
updateImportJobProgress,
|
||||||
} from "@sofa/core/imports";
|
} 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 { createLogger } from "@sofa/logger";
|
||||||
|
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
@@ -100,34 +100,18 @@ export const createJob = os.imports.createJob.use(authed).handler(async ({ input
|
|||||||
|
|
||||||
// Prevent concurrent imports per user.
|
// Prevent concurrent imports per user.
|
||||||
// Auto-cancel stale *pending* jobs (server crashed before worker started).
|
// 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 PENDING_STALE_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const existing = db
|
const existing = getActiveImportJobForUser(context.user.id);
|
||||||
.select()
|
|
||||||
.from(importJobs)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(importJobs.userId, context.user.id),
|
|
||||||
inArray(importJobs.status, ["pending", "running"]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const isPending = existing.status === "pending";
|
const isPending = existing.status === "pending";
|
||||||
const isStale = isPending && now - existing.createdAt.getTime() > PENDING_STALE_MS;
|
const isStale = isPending && now - existing.createdAt.getTime() > PENDING_STALE_MS;
|
||||||
if (isStale) {
|
if (isStale) {
|
||||||
// Mark as cancelled so the worker loop also stops if it starts late
|
updateImportJobProgress(existing.id, {
|
||||||
db.update(importJobs)
|
status: "cancelled",
|
||||||
.set({
|
finishedAt: new Date(),
|
||||||
status: "cancelled",
|
currentMessage: "Import timed out (stale job auto-cancelled)",
|
||||||
finishedAt: new Date(),
|
});
|
||||||
currentMessage: "Import timed out (stale job auto-cancelled)",
|
|
||||||
})
|
|
||||||
.where(eq(importJobs.id, existing.id))
|
|
||||||
.run();
|
|
||||||
log.warn(`Auto-cancelled stale import job ${existing.id}`);
|
log.warn(`Auto-cancelled stale import job ${existing.id}`);
|
||||||
} else {
|
} else {
|
||||||
throw new ORPCError("CONFLICT", {
|
throw new ORPCError("CONFLICT", {
|
||||||
@@ -137,20 +121,16 @@ export const createJob = os.imports.createJob.use(authed).handler(async ({ input
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = db
|
const job = insertImportJob({
|
||||||
.insert(importJobs)
|
userId: context.user.id,
|
||||||
.values({
|
source: data.source,
|
||||||
userId: context.user.id,
|
status: "pending",
|
||||||
source: data.source,
|
payload: JSON.stringify(data),
|
||||||
status: "pending",
|
importWatches: options.importWatches,
|
||||||
payload: JSON.stringify(data),
|
importWatchlist: options.importWatchlist,
|
||||||
importWatches: options.importWatches,
|
importRatings: options.importRatings,
|
||||||
importWatchlist: options.importWatchlist,
|
createdAt: new Date(),
|
||||||
importRatings: options.importRatings,
|
});
|
||||||
createdAt: new Date(),
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
.get();
|
|
||||||
|
|
||||||
// Fire-and-forget processing
|
// Fire-and-forget processing
|
||||||
processImportJob(job.id).catch((err) => {
|
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 },
|
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);
|
return readImportJob(input.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +1,35 @@
|
|||||||
import { ORPCError } from "@orpc/server";
|
import { ORPCError } from "@orpc/server";
|
||||||
|
|
||||||
import { AppErrorCode } from "@sofa/api/errors";
|
import { AppErrorCode } from "@sofa/api/errors";
|
||||||
import { db } from "@sofa/db/client";
|
import {
|
||||||
import { and, desc, eq } from "@sofa/db/helpers";
|
createOrUpdateIntegration,
|
||||||
import { integrationEvents, integrations } from "@sofa/db/schema";
|
deleteIntegration as coreDeleteIntegration,
|
||||||
|
listUserIntegrations,
|
||||||
|
regenerateToken as coreRegenerateToken,
|
||||||
|
serializeIntegration,
|
||||||
|
} from "@sofa/core/integrations";
|
||||||
|
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
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 }) => {
|
export const list = os.integrations.list.use(authed).handler(({ context }) => {
|
||||||
const userIntegrations = db
|
return listUserIntegrations(context.user.id);
|
||||||
.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 };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const create = os.integrations.create.use(authed).handler(({ input, context }) => {
|
export const create = os.integrations.create.use(authed).handler(({ input, context }) => {
|
||||||
const existing = db
|
return createOrUpdateIntegration(context.user.id, input.provider, input.enabled);
|
||||||
.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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteIntegration = os.integrations.delete
|
export const deleteIntegration = os.integrations.delete
|
||||||
.use(authed)
|
.use(authed)
|
||||||
.handler(({ input, context }) => {
|
.handler(({ input, context }) => {
|
||||||
db.delete(integrations)
|
coreDeleteIntegration(context.user.id, input.provider);
|
||||||
.where(
|
|
||||||
and(eq(integrations.userId, context.user.id), eq(integrations.provider, input.provider)),
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const regenerateToken = os.integrations.regenerateToken
|
export const regenerateToken = os.integrations.regenerateToken
|
||||||
.use(authed)
|
.use(authed)
|
||||||
.handler(({ input, context }) => {
|
.handler(({ input, context }) => {
|
||||||
const row = db
|
const row = coreRegenerateToken(context.user.id, input.provider);
|
||||||
.update(integrations)
|
|
||||||
.set({ token: generateToken() })
|
|
||||||
.where(
|
|
||||||
and(eq(integrations.userId, context.user.id), eq(integrations.provider, input.provider)),
|
|
||||||
)
|
|
||||||
.returning()
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new ORPCError("NOT_FOUND", {
|
throw new ORPCError("NOT_FOUND", {
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { logEpisodeWatchBatch, unwatchSeason } from "@sofa/core/tracking";
|
import { unwatchSeason, watchSeason } from "@sofa/core/tracking";
|
||||||
import { db } from "@sofa/db/client";
|
|
||||||
import { eq } from "@sofa/db/helpers";
|
|
||||||
import { episodes } from "@sofa/db/schema";
|
|
||||||
|
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
import { authed } from "../middleware";
|
||||||
|
|
||||||
export const watch = os.seasons.watch.use(authed).handler(({ input, context }) => {
|
export const watch = os.seasons.watch.use(authed).handler(({ input, context }) => {
|
||||||
const seasonEps = db.select().from(episodes).where(eq(episodes.seasonId, input.id)).all();
|
watchSeason(context.user.id, input.id);
|
||||||
logEpisodeWatchBatch(
|
|
||||||
context.user.id,
|
|
||||||
seasonEps.map((ep) => ep.id),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const unwatch = os.seasons.unwatch.use(authed).handler(({ input, context }) => {
|
export const unwatch = os.seasons.unwatch.use(authed).handler(({ input, context }) => {
|
||||||
|
|||||||
@@ -8,13 +8,11 @@ import {
|
|||||||
getUserTitleInfo,
|
getUserTitleInfo,
|
||||||
logMovieWatch,
|
logMovieWatch,
|
||||||
markAllEpisodesWatched,
|
markAllEpisodesWatched,
|
||||||
|
quickAddTitle,
|
||||||
rateTitleStars,
|
rateTitleStars,
|
||||||
removeTitleStatus,
|
removeTitleStatus,
|
||||||
setTitleStatus,
|
setTitleStatus,
|
||||||
} from "@sofa/core/tracking";
|
} 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 { os } from "../context";
|
||||||
import { authed } from "../middleware";
|
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 }) => {
|
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 result = quickAddTitle(context.user.id, input.id);
|
||||||
const title = db
|
if (!result) {
|
||||||
.select({ id: titles.id, tmdbId: titles.tmdbId, type: titles.type })
|
|
||||||
.from(titles)
|
|
||||||
.where(eq(titles.id, input.id))
|
|
||||||
.get();
|
|
||||||
if (!title) {
|
|
||||||
throw new ORPCError("NOT_FOUND", {
|
throw new ORPCError("NOT_FOUND", {
|
||||||
message: "Title not found",
|
message: "Title not found",
|
||||||
data: { code: AppErrorCode.TITLE_NOT_FOUND },
|
data: { code: AppErrorCode.TITLE_NOT_FOUND },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger full TMDB import if still a shell
|
// Trigger full TMDB import if still a shell (fire-and-forget)
|
||||||
getOrFetchTitleByTmdbId(title.tmdbId, title.type as "movie" | "tv").catch(() => {});
|
getOrFetchTitleByTmdbId(result.tmdbId, result.type as "movie" | "tv").catch(() => {});
|
||||||
|
|
||||||
const existing = db
|
return { id: result.id, alreadyAdded: result.alreadyAdded };
|
||||||
.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 };
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
import { findIntegrationByToken } from "@sofa/core/integrations";
|
||||||
import type { WebhookEvent } from "@sofa/core/webhooks";
|
import type { WebhookEvent } from "@sofa/core/webhooks";
|
||||||
import {
|
import {
|
||||||
parseEmbyPayload,
|
parseEmbyPayload,
|
||||||
@@ -7,9 +8,6 @@ import {
|
|||||||
parsePlexPayload,
|
parsePlexPayload,
|
||||||
processWebhook,
|
processWebhook,
|
||||||
} from "@sofa/core/webhooks";
|
} 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";
|
import { createLogger } from "@sofa/logger";
|
||||||
|
|
||||||
const log = createLogger("webhooks");
|
const log = createLogger("webhooks");
|
||||||
@@ -20,7 +18,7 @@ app.post("/:token", async (c) => {
|
|||||||
const token = c.req.param("token");
|
const token = c.req.param("token");
|
||||||
|
|
||||||
// Look up connection by token — this IS the auth
|
// 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) {
|
if (!connection || !connection.enabled) {
|
||||||
// Always return 200 to avoid retry storms from media servers
|
// Always return 200 to avoid retry storms from media servers
|
||||||
|
|||||||
@@ -9,10 +9,12 @@
|
|||||||
"./cache": "./src/cache.ts",
|
"./cache": "./src/cache.ts",
|
||||||
"./colors": "./src/colors.ts",
|
"./colors": "./src/colors.ts",
|
||||||
"./credits": "./src/credits.ts",
|
"./credits": "./src/credits.ts",
|
||||||
|
"./cron": "./src/cron.ts",
|
||||||
"./discovery": "./src/discovery.ts",
|
"./discovery": "./src/discovery.ts",
|
||||||
"./image-cache": "./src/image-cache.ts",
|
"./image-cache": "./src/image-cache.ts",
|
||||||
"./imports": "./src/imports/index.ts",
|
"./imports": "./src/imports/index.ts",
|
||||||
"./imports/parsers": "./src/imports/parsers.ts",
|
"./imports/parsers": "./src/imports/parsers.ts",
|
||||||
|
"./integrations": "./src/integrations.ts",
|
||||||
"./lists": "./src/lists.ts",
|
"./lists": "./src/lists.ts",
|
||||||
"./metadata": "./src/metadata.ts",
|
"./metadata": "./src/metadata.ts",
|
||||||
"./person": "./src/person.ts",
|
"./person": "./src/person.ts",
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { db } from "@sofa/db/client";
|
import {
|
||||||
import { and, eq } from "@sofa/db/helpers";
|
getAvailabilityOffers,
|
||||||
import { availabilityOffers, titles } from "@sofa/db/schema";
|
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 { createLogger } from "@sofa/logger";
|
||||||
import { getWatchProviders } from "@sofa/tmdb/client";
|
import { getWatchProviders } from "@sofa/tmdb/client";
|
||||||
|
|
||||||
const log = createLogger("availability");
|
const log = createLogger("availability");
|
||||||
|
|
||||||
export async function refreshAvailability(titleId: string) {
|
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;
|
if (!title) return;
|
||||||
|
|
||||||
const data = await getWatchProviders(title.tmdbId, title.type);
|
const data = await getWatchProviders(title.tmdbId, title.type);
|
||||||
@@ -39,20 +42,12 @@ export async function refreshAvailability(titleId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db.transaction((tx) => {
|
replaceAvailabilityTransaction(titleId, "US", allOfferRows);
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = offerTypes.reduce((n, t) => n + (us[t]?.length ?? 0), 0);
|
const total = offerTypes.reduce((n, t) => n + (us[t]?.length ?? 0), 0);
|
||||||
log.debug(`Refreshed availability for title ${titleId}: ${total} offers`);
|
log.debug(`Refreshed availability for title ${titleId}: ${total} offers`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAvailability(titleId: string) {
|
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 path from "node:path";
|
||||||
|
|
||||||
import { BACKUP_DIR, DATABASE_URL } from "@sofa/config";
|
import { BACKUP_DIR, DATABASE_URL } from "@sofa/config";
|
||||||
import { closeDatabase, db } from "@sofa/db/client";
|
import { closeDatabase } from "@sofa/db/client";
|
||||||
import { sql } from "@sofa/db/helpers";
|
|
||||||
import { runMigrations } from "@sofa/db/migrate";
|
import { runMigrations } from "@sofa/db/migrate";
|
||||||
|
import { vacuumInto } from "@sofa/db/utils";
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
|
|
||||||
function formatTimestamp(date: Date): string {
|
function formatTimestamp(date: Date): string {
|
||||||
@@ -138,8 +138,7 @@ async function createBackupInternal(prefix: BackupPrefix): Promise<BackupInfo> {
|
|||||||
const filename = `${prefix}-${timestamp}.db`;
|
const filename = `${prefix}-${timestamp}.db`;
|
||||||
const dest = path.join(BACKUP_DIR, filename);
|
const dest = path.join(BACKUP_DIR, filename);
|
||||||
|
|
||||||
// VACUUM INTO atomically creates a clean, self-contained copy (safe for WAL mode)
|
vacuumInto(dest);
|
||||||
db.run(sql.raw(`VACUUM INTO '${dest.replace(/'/g, "''")}'`));
|
|
||||||
|
|
||||||
const s = await Bun.file(dest).stat();
|
const s = await Bun.file(dest).stat();
|
||||||
log.info(`Created backup: ${filename} (${s.size} bytes)`);
|
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 path from "node:path";
|
||||||
|
|
||||||
import { CACHE_DIR } from "@sofa/config";
|
import { CACHE_DIR } from "@sofa/config";
|
||||||
import { db } from "@sofa/db/client";
|
import { purgeShellTitlesTransaction } from "@sofa/db/queries/cache";
|
||||||
import { inArray, isNull, notInArray } from "@sofa/db/helpers";
|
|
||||||
import { persons, titleCast, titles, userTitleStatus } from "@sofa/db/schema";
|
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
|
|
||||||
const log = createLogger("purge");
|
const log = createLogger("purge");
|
||||||
|
|
||||||
const BATCH_SIZE = 500;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete un-enriched "shell" titles that aren't in any user's library,
|
* Delete un-enriched "shell" titles that aren't in any user's library,
|
||||||
* then clean up orphaned person records.
|
* then clean up orphaned person records.
|
||||||
@@ -19,73 +15,19 @@ export function purgeMetadataCache(): {
|
|||||||
deletedTitles: number;
|
deletedTitles: number;
|
||||||
deletedPersons: number;
|
deletedPersons: number;
|
||||||
} {
|
} {
|
||||||
return db.transaction(() => {
|
const result = purgeShellTitlesTransaction();
|
||||||
// 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();
|
|
||||||
|
|
||||||
if (shellTitles.length === 0) {
|
if (result.deletedTitles > 0) {
|
||||||
log.info("No un-enriched titles to purge");
|
log.info(`Purged ${result.deletedTitles} un-enriched titles`);
|
||||||
return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons() };
|
} else {
|
||||||
}
|
log.info("No un-enriched titles to purge");
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
const deletedPersons = ids.length;
|
|
||||||
|
|
||||||
log.info(`Purged ${deletedPersons} orphaned persons`);
|
if (result.deletedPersons > 0) {
|
||||||
return deletedPersons;
|
log.info(`Purged ${result.deletedPersons} orphaned persons`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Image cache subdirectories to scan */
|
/** Image cache subdirectories to scan */
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Vibrant } from "node-vibrant/node";
|
import { Vibrant } from "node-vibrant/node";
|
||||||
|
|
||||||
import { db } from "@sofa/db/client";
|
import { updateTitleColorPalette } from "@sofa/db/queries/colors";
|
||||||
import { eq } from "@sofa/db/helpers";
|
|
||||||
import { titles } from "@sofa/db/schema";
|
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
|
|
||||||
import { loadImageBuffer } from "./image-cache";
|
import { loadImageBuffer } from "./image-cache";
|
||||||
@@ -24,7 +22,7 @@ export async function extractAndStoreColors(
|
|||||||
sourceBuffer?: Buffer | null,
|
sourceBuffer?: Buffer | null,
|
||||||
): Promise<ColorPalette | null> {
|
): Promise<ColorPalette | null> {
|
||||||
if (!posterPath) {
|
if (!posterPath) {
|
||||||
db.update(titles).set({ colorPalette: null }).where(eq(titles.id, titleId)).run();
|
updateTitleColorPalette(titleId, null);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,10 +42,7 @@ export async function extractAndStoreColors(
|
|||||||
lightMuted: palette.LightMuted?.hex ?? null,
|
lightMuted: palette.LightMuted?.hex ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.update(titles)
|
updateTitleColorPalette(titleId, JSON.stringify(colors));
|
||||||
.set({ colorPalette: JSON.stringify(colors) })
|
|
||||||
.where(eq(titles.id, titleId))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
log.debug(`Extracted colors for title ${titleId}: colors=${JSON.stringify(colors)}`);
|
log.debug(`Extracted colors for title ${titleId}: colors=${JSON.stringify(colors)}`);
|
||||||
return colors;
|
return colors;
|
||||||
|
|||||||
+18
-127
@@ -1,7 +1,14 @@
|
|||||||
import type { CastMember } from "@sofa/api/schemas";
|
import type { CastMember } from "@sofa/api/schemas";
|
||||||
import { db } from "@sofa/db/client";
|
import {
|
||||||
import { eq, inArray, sql } from "@sofa/db/helpers";
|
batchUpsertPersonsTransaction,
|
||||||
import { persons, titleCast, titles } from "@sofa/db/schema";
|
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 { createLogger } from "@sofa/logger";
|
||||||
import { getMovieCredits, getTvAggregateCredits } from "@sofa/tmdb/client";
|
import { getMovieCredits, getTvAggregateCredits } from "@sofa/tmdb/client";
|
||||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
@@ -38,68 +45,15 @@ function batchUpsertPersons(people: PersonData[]): Map<number, string> {
|
|||||||
const tmdbIds = uniquePeople.map((p) => p.tmdbId);
|
const tmdbIds = uniquePeople.map((p) => p.tmdbId);
|
||||||
|
|
||||||
// Batch prefetch existing persons (1 query)
|
// Batch prefetch existing persons (1 query)
|
||||||
const existing = db
|
const existing = getExistingPersonsByTmdbIds(tmdbIds);
|
||||||
.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 existingMap = new Map(existing.map((p) => [p.tmdbId, p]));
|
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) => {
|
const idMap = batchUpsertPersonsTransaction(uniquePeople, existingMap);
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// One fallback query for any concurrent inserts that conflicted
|
// One fallback query for any concurrent inserts that conflicted
|
||||||
const stillMissing = uniquePeople.filter((p) => !idMap.has(p.tmdbId)).map((p) => p.tmdbId);
|
const stillMissing = uniquePeople.filter((p) => !idMap.has(p.tmdbId)).map((p) => p.tmdbId);
|
||||||
if (stillMissing.length > 0) {
|
if (stillMissing.length > 0) {
|
||||||
const fallbacks = db
|
const fallbacks = getFallbackPersonsByTmdbIds(stillMissing);
|
||||||
.select({ id: persons.id, tmdbId: persons.tmdbId })
|
|
||||||
.from(persons)
|
|
||||||
.where(inArray(persons.tmdbId, stillMissing))
|
|
||||||
.all();
|
|
||||||
for (const f of fallbacks) idMap.set(f.tmdbId, f.id);
|
for (const f of fallbacks) idMap.set(f.tmdbId, f.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,16 +69,7 @@ export async function syncCastProfileThumbHashes(
|
|||||||
await cacheProfilePhotos(titleId);
|
await cacheProfilePhotos(titleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const personRows = db
|
const personRows = getPersonsForTitleCast(titleId);
|
||||||
.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 allowedIds = personIds ? new Set(personIds) : null;
|
const allowedIds = personIds ? new Set(personIds) : null;
|
||||||
const uniqueRows = new Map(personRows.map((p) => [p.id, p]));
|
const uniqueRows = new Map(personRows.map((p) => [p.id, p]));
|
||||||
@@ -137,7 +82,7 @@ export async function syncCastProfileThumbHashes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshCredits(titleId: string) {
|
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;
|
if (!title) return;
|
||||||
|
|
||||||
log.debug(`Refreshing credits for "${title.title}" (${title.type})`);
|
log.debug(`Refreshing credits for "${title.title}" (${title.type})`);
|
||||||
@@ -211,25 +156,7 @@ export async function refreshCredits(titleId: string) {
|
|||||||
});
|
});
|
||||||
crewOrder++;
|
crewOrder++;
|
||||||
}
|
}
|
||||||
if (allCastRows.length > 0) {
|
batchUpsertTitleCast(allCastRows);
|
||||||
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();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const credits = await getTvAggregateCredits(title.tmdbId);
|
const credits = await getTvAggregateCredits(title.tmdbId);
|
||||||
const tvCast = credits.cast ?? [];
|
const tvCast = credits.cast ?? [];
|
||||||
@@ -309,25 +236,7 @@ export async function refreshCredits(titleId: string) {
|
|||||||
});
|
});
|
||||||
crewOrder++;
|
crewOrder++;
|
||||||
}
|
}
|
||||||
if (allCastRows.length > 0) {
|
batchUpsertTitleCast(allCastRows);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug(`Credits refreshed for "${title.title}"`);
|
log.debug(`Credits refreshed for "${title.title}"`);
|
||||||
@@ -341,25 +250,7 @@ export async function refreshCredits(titleId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getCastForTitle(titleId: string): CastMember[] {
|
export function getCastForTitle(titleId: string): CastMember[] {
|
||||||
const rows = db
|
const rows = getCastForTitleJoined(titleId);
|
||||||
.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();
|
|
||||||
|
|
||||||
return rows.map((r) =>
|
return rows.map((r) =>
|
||||||
Object.assign(r, { profilePath: tmdbImageUrl(r.profilePath, "profiles") }),
|
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 {
|
import {
|
||||||
availabilityOffers,
|
getAllTrackedTitleIds,
|
||||||
episodes,
|
getCompletedTitleIds,
|
||||||
seasons,
|
getEpisodesBySeasonIds,
|
||||||
titleRecommendations,
|
getEpisodeWatchCountSince,
|
||||||
titles,
|
getEpisodeWatchesByEpisodeIds,
|
||||||
userEpisodeWatches,
|
getEpisodeWatchHistoryBuckets,
|
||||||
userMovieWatches,
|
getHighlyRatedTitleIds,
|
||||||
userRatings,
|
getInProgressTitleIds,
|
||||||
userTitleStatus,
|
getLibraryFeed,
|
||||||
} from "@sofa/db/schema";
|
getMovieWatchCountSince,
|
||||||
|
getMovieWatchHistoryBuckets,
|
||||||
|
getNewAvailableFeed,
|
||||||
|
getRecommendationRows,
|
||||||
|
getRecommendationRowsForTitle,
|
||||||
|
getSeasonsByTitleIds,
|
||||||
|
getTitleByIdOrNull,
|
||||||
|
getTitlesByIds,
|
||||||
|
getTvTitlesByIds,
|
||||||
|
getUserStatusCounts,
|
||||||
|
} from "@sofa/db/queries/discovery";
|
||||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
|
|
||||||
export type TimePeriod = "today" | "this_week" | "this_month" | "this_year";
|
export type TimePeriod = "today" | "this_week" | "this_month" | "this_year";
|
||||||
@@ -44,13 +52,10 @@ export function getWatchCount(
|
|||||||
period: TimePeriod,
|
period: TimePeriod,
|
||||||
): number {
|
): number {
|
||||||
const timestamp = periodStartTimestamp(period);
|
const timestamp = periodStartTimestamp(period);
|
||||||
const watchTable = table === "movies" ? userMovieWatches : userEpisodeWatches;
|
if (table === "movies") {
|
||||||
const [row] = db
|
return getMovieWatchCountSince(userId, timestamp);
|
||||||
.select({ count: sql<number>`count(*)` })
|
}
|
||||||
.from(watchTable)
|
return getEpisodeWatchCountSince(userId, timestamp);
|
||||||
.where(and(eq(watchTable.userId, userId), sql`${watchTable.watchedAt} >= ${timestamp}`))
|
|
||||||
.all();
|
|
||||||
return row?.count ?? 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryBucket {
|
export interface HistoryBucket {
|
||||||
@@ -71,7 +76,6 @@ export function getWatchHistory(
|
|||||||
period: TimePeriod,
|
period: TimePeriod,
|
||||||
): HistoryBucket[] {
|
): HistoryBucket[] {
|
||||||
const startTs = periodStartTimestamp(period);
|
const startTs = periodStartTimestamp(period);
|
||||||
const watchTable = table === "movies" ? userMovieWatches : userEpisodeWatches;
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
let fmt: string;
|
let fmt: string;
|
||||||
@@ -114,17 +118,10 @@ export function getWatchHistory(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = db
|
const rows =
|
||||||
.select({
|
table === "movies"
|
||||||
bucket: sql<string>`strftime(${fmt}, ${watchTable.watchedAt}, 'unixepoch', 'localtime')`.as(
|
? getMovieWatchHistoryBuckets(userId, startTs, fmt)
|
||||||
"bucket",
|
: getEpisodeWatchHistoryBuckets(userId, startTs, fmt);
|
||||||
),
|
|
||||||
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 countMap = new Map(rows.map((r) => [r.bucket, r.count]));
|
const countMap = new Map(rows.map((r) => [r.bucket, r.count]));
|
||||||
return buckets.map((b) => ({ bucket: b, count: countMap.get(b) ?? 0 }));
|
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 moviesThisMonth = getWatchCount(userId, "movies", "this_month");
|
||||||
const episodesThisWeek = getWatchCount(userId, "episodes", "this_week");
|
const episodesThisWeek = getWatchCount(userId, "episodes", "this_week");
|
||||||
|
|
||||||
const [statusCounts] = db
|
const statusCounts = getUserStatusCounts(userId);
|
||||||
.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 {
|
return {
|
||||||
moviesThisMonth,
|
moviesThisMonth,
|
||||||
@@ -183,25 +173,14 @@ export interface ContinueWatchingItem {
|
|||||||
|
|
||||||
export function getContinueWatchingFeed(userId: string): ContinueWatchingItem[] {
|
export function getContinueWatchingFeed(userId: string): ContinueWatchingItem[] {
|
||||||
// Get in-progress TV shows
|
// Get in-progress TV shows
|
||||||
const inProgress = db
|
const inProgress = getInProgressTitleIds(userId);
|
||||||
.select({
|
|
||||||
titleId: userTitleStatus.titleId,
|
|
||||||
updatedAt: userTitleStatus.updatedAt,
|
|
||||||
})
|
|
||||||
.from(userTitleStatus)
|
|
||||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.status, "in_progress")))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (inProgress.length === 0) return [];
|
if (inProgress.length === 0) return [];
|
||||||
|
|
||||||
const titleIds = inProgress.map((r) => r.titleId);
|
const titleIds = inProgress.map((r) => r.titleId);
|
||||||
|
|
||||||
// Batch fetch all TV titles (1 query)
|
// Batch fetch all TV titles (1 query)
|
||||||
const tvTitles = db
|
const tvTitles = getTvTitlesByIds(titleIds);
|
||||||
.select()
|
|
||||||
.from(titles)
|
|
||||||
.where(and(inArray(titles.id, titleIds), eq(titles.type, "tv")))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (tvTitles.length === 0) return [];
|
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]));
|
const titleMap = new Map(tvTitles.map((t) => [t.id, t]));
|
||||||
|
|
||||||
// Batch fetch all seasons for these titles (1 query)
|
// Batch fetch all seasons for these titles (1 query)
|
||||||
const allSeasons = db
|
const allSeasons = getSeasonsByTitleIds(tvTitleIds);
|
||||||
.select()
|
|
||||||
.from(seasons)
|
|
||||||
.where(inArray(seasons.titleId, tvTitleIds))
|
|
||||||
.orderBy(seasons.titleId, seasons.seasonNumber)
|
|
||||||
.all();
|
|
||||||
|
|
||||||
const seasonIds = allSeasons.map((s) => s.id);
|
const seasonIds = allSeasons.map((s) => s.id);
|
||||||
|
|
||||||
// Batch fetch all episodes for these seasons (1 query)
|
// Batch fetch all episodes for these seasons (1 query)
|
||||||
const allEpisodes =
|
const allEpisodes = getEpisodesBySeasonIds(seasonIds);
|
||||||
seasonIds.length > 0
|
|
||||||
? db
|
|
||||||
.select()
|
|
||||||
.from(episodes)
|
|
||||||
.where(inArray(episodes.seasonId, seasonIds))
|
|
||||||
.orderBy(episodes.seasonId, episodes.episodeNumber)
|
|
||||||
.all()
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Batch fetch all watches for this user for these episodes (1 query)
|
// Batch fetch all watches for this user for these episodes (1 query)
|
||||||
const episodeIds = allEpisodes.map((ep) => ep.id);
|
const episodeIds = allEpisodes.map((ep) => ep.id);
|
||||||
const allWatches =
|
const allWatches = getEpisodeWatchesByEpisodeIds(userId, episodeIds);
|
||||||
episodeIds.length > 0
|
|
||||||
? db
|
|
||||||
.select()
|
|
||||||
.from(userEpisodeWatches)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userEpisodeWatches.userId, userId),
|
|
||||||
inArray(userEpisodeWatches.episodeId, episodeIds),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Build lookup maps
|
// Build lookup maps
|
||||||
const watchedEpisodeIds = new Set(allWatches.map((w) => w.episodeId));
|
const watchedEpisodeIds = new Set(allWatches.map((w) => w.episodeId));
|
||||||
@@ -339,123 +293,24 @@ export function getContinueWatchingFeed(userId: string): ContinueWatchingItem[]
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNewAvailableFeed(userId: string, _days = 14) {
|
export { getNewAvailableFeed } from "@sofa/db/queries/discovery";
|
||||||
// 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();
|
|
||||||
|
|
||||||
return results;
|
export { getLibraryFeed } from "@sofa/db/queries/discovery";
|
||||||
}
|
|
||||||
|
|
||||||
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 getRecommendationsFeed(userId: string) {
|
export function getRecommendationsFeed(userId: string) {
|
||||||
// Get recommendations from user's highly-rated or completed titles
|
// Get recommendations from user's highly-rated or completed titles
|
||||||
const userCompletedOrRated = db
|
const userCompletedOrRated = getCompletedTitleIds(userId);
|
||||||
.select({ titleId: userTitleStatus.titleId })
|
|
||||||
.from(userTitleStatus)
|
|
||||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.status, "completed")))
|
|
||||||
.all()
|
|
||||||
.map((r) => r.titleId);
|
|
||||||
|
|
||||||
const ratedIds = db
|
const ratedIds = getHighlyRatedTitleIds(userId);
|
||||||
.select({ titleId: userRatings.titleId })
|
|
||||||
.from(userRatings)
|
|
||||||
.where(and(eq(userRatings.userId, userId), sql`${userRatings.ratingStars} >= 4`))
|
|
||||||
.all()
|
|
||||||
.map((r) => r.titleId);
|
|
||||||
|
|
||||||
const sourceIds = [...new Set([...userCompletedOrRated, ...ratedIds])];
|
const sourceIds = [...new Set([...userCompletedOrRated, ...ratedIds])];
|
||||||
if (sourceIds.length === 0) return [];
|
if (sourceIds.length === 0) return [];
|
||||||
|
|
||||||
// Get all tracked title IDs to exclude
|
// Get all tracked title IDs to exclude
|
||||||
const trackedIds = new Set(
|
const trackedIds = new Set(getAllTrackedTitleIds(userId));
|
||||||
db
|
|
||||||
.select({ titleId: userTitleStatus.titleId })
|
|
||||||
.from(userTitleStatus)
|
|
||||||
.where(eq(userTitleStatus.userId, userId))
|
|
||||||
.all()
|
|
||||||
.map((r) => r.titleId),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Batch fetch all recommendations for all source IDs (1 query)
|
// Batch fetch all recommendations for all source IDs (1 query)
|
||||||
const allRecRows = db
|
const allRecRows = getRecommendationRows(sourceIds);
|
||||||
.select({
|
|
||||||
recommendedTitleId: titleRecommendations.recommendedTitleId,
|
|
||||||
rank: titleRecommendations.rank,
|
|
||||||
})
|
|
||||||
.from(titleRecommendations)
|
|
||||||
.where(inArray(titleRecommendations.titleId, sourceIds))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
const recs: Map<string, { titleId: string; score: number }> = new Map();
|
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)
|
// Batch fetch all recommended titles (1 query)
|
||||||
const recTitleIds = sorted.map((r) => r.titleId);
|
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]));
|
const recTitleMap = new Map(recTitles.map((t) => [t.id, t]));
|
||||||
|
|
||||||
return sorted.map((r) => recTitleMap.get(r.titleId)).filter(Boolean);
|
return sorted.map((r) => recTitleMap.get(r.titleId)).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRecommendationsForTitle(titleId: string) {
|
export function getRecommendationsForTitle(titleId: string) {
|
||||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
const title = getTitleByIdOrNull(titleId);
|
||||||
if (!title) return [];
|
if (!title) return [];
|
||||||
|
|
||||||
const recs = db
|
const recs = getRecommendationRowsForTitle(titleId);
|
||||||
.select({
|
|
||||||
recommendedTitleId: titleRecommendations.recommendedTitleId,
|
|
||||||
source: titleRecommendations.source,
|
|
||||||
rank: titleRecommendations.rank,
|
|
||||||
})
|
|
||||||
.from(titleRecommendations)
|
|
||||||
.where(eq(titleRecommendations.titleId, titleId))
|
|
||||||
.orderBy(titleRecommendations.rank)
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (recs.length === 0) return [];
|
if (recs.length === 0) return [];
|
||||||
|
|
||||||
@@ -521,7 +367,7 @@ export function getRecommendationsForTitle(titleId: string) {
|
|||||||
|
|
||||||
// Batch fetch all recommended titles (1 query)
|
// Batch fetch all recommended titles (1 query)
|
||||||
const recTitleIds = uniqueRecs.map((r) => r.recommendedTitleId);
|
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]));
|
const recTitleMap = new Map(recTitles.map((t) => [t.id, t]));
|
||||||
|
|
||||||
return uniqueRecs
|
return uniqueRecs
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ import { mkdir, rename } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { CACHE_DIR, TMDB_IMAGE_BASE_URL } from "@sofa/config";
|
import { CACHE_DIR, TMDB_IMAGE_BASE_URL } from "@sofa/config";
|
||||||
import { db } from "@sofa/db/client";
|
import {
|
||||||
import { eq, inArray } from "@sofa/db/helpers";
|
getAvailabilityLogosForTitle,
|
||||||
import { availabilityOffers, episodes, persons, seasons, titleCast, titles } from "@sofa/db/schema";
|
getCastProfilePathsForTitle,
|
||||||
|
getEpisodeStillsForTitle,
|
||||||
|
getSeasonPostersForTitle,
|
||||||
|
getTitleWithPaths,
|
||||||
|
} from "@sofa/db/queries/image-cache";
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
import { IMAGE_CATEGORY_SIZES, type ImageCategory, tmdbCdnImageUrl } from "@sofa/tmdb/image";
|
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) {
|
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;
|
if (!title) return;
|
||||||
|
|
||||||
// Collect all candidate images, then check cache in parallel
|
// 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.backdropPath) candidates.push({ imgPath: title.backdropPath, category: "backdrops" });
|
||||||
|
|
||||||
if (title.type === "tv") {
|
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) {
|
for (const s of allSeasons) {
|
||||||
if (s.posterPath) candidates.push({ imgPath: s.posterPath, category: "posters" });
|
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) {
|
export async function cacheEpisodeStills(titleId: string) {
|
||||||
const allSeasons = db.select().from(seasons).where(eq(seasons.titleId, titleId)).all();
|
const allEps = getEpisodeStillsForTitle(titleId);
|
||||||
|
|
||||||
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 epsWithStills = allEps.filter(
|
const epsWithStills = allEps.filter(
|
||||||
(ep): ep is typeof ep & { stillPath: string } => ep.stillPath != null,
|
(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) {
|
export async function cacheProviderLogos(titleId: string) {
|
||||||
const offers = db
|
const offers = getAvailabilityLogosForTitle(titleId);
|
||||||
.select()
|
|
||||||
.from(availabilityOffers)
|
|
||||||
.where(eq(availabilityOffers.titleId, titleId))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
// Deduplicate and parallel cache checks
|
// Deduplicate and parallel cache checks
|
||||||
const uniqueLogos = new Map<string, string>();
|
const uniqueLogos = new Map<string, string>();
|
||||||
@@ -217,12 +212,7 @@ export async function cacheProviderLogos(titleId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cacheProfilePhotos(titleId: string) {
|
export async function cacheProfilePhotos(titleId: string) {
|
||||||
const castRows = db
|
const castRows = getCastProfilePathsForTitle(titleId);
|
||||||
.select({ profilePath: persons.profilePath })
|
|
||||||
.from(titleCast)
|
|
||||||
.innerJoin(persons, eq(titleCast.personId, persons.id))
|
|
||||||
.where(eq(titleCast.titleId, titleId))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
// Deduplicate and parallel cache checks
|
// Deduplicate and parallel cache checks
|
||||||
const uniqueProfiles = new Map<string, string>();
|
const uniqueProfiles = new Map<string, string>();
|
||||||
|
|||||||
@@ -18,4 +18,9 @@ export {
|
|||||||
processImportJob,
|
processImportJob,
|
||||||
readImportJob,
|
readImportJob,
|
||||||
} from "./processor";
|
} from "./processor";
|
||||||
|
export {
|
||||||
|
getActiveImportJobForUser,
|
||||||
|
insertImportJob,
|
||||||
|
updateImportJobProgress,
|
||||||
|
} from "@sofa/db/queries/imports";
|
||||||
export { type ExternalIds, resolveMovieTmdbId, resolveShowTmdbId } from "./resolve";
|
export { type ExternalIds, resolveMovieTmdbId, resolveShowTmdbId } from "./resolve";
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { type ImportJob, NormalizedImportSchema } from "@sofa/api/schemas";
|
import { type ImportJob, NormalizedImportSchema } from "@sofa/api/schemas";
|
||||||
import { db } from "@sofa/db/client";
|
|
||||||
import { and, eq } from "@sofa/db/helpers";
|
|
||||||
import {
|
import {
|
||||||
episodes,
|
getImportJob,
|
||||||
importJobs,
|
getImportJobStatus,
|
||||||
seasons,
|
hasEpisodeWatch,
|
||||||
userEpisodeWatches,
|
hasMovieWatch,
|
||||||
userMovieWatches,
|
hasRating,
|
||||||
userRatings,
|
hasTitleStatus,
|
||||||
userTitleStatus,
|
updateImportJobProgress,
|
||||||
} from "@sofa/db/schema";
|
} from "@sofa/db/queries/imports";
|
||||||
|
import { findEpisodeBySeasonAndNumber, findSeasonByTitleAndNumber } from "@sofa/db/queries/title";
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
|
|
||||||
import { getOrFetchTitleByTmdbId } from "../metadata";
|
import { getOrFetchTitleByTmdbId } from "../metadata";
|
||||||
@@ -46,44 +45,6 @@ export interface ImportResult {
|
|||||||
warnings: string[];
|
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 ────────────────────────────────────────────────
|
// ─── Item Processors ────────────────────────────────────────────────
|
||||||
|
|
||||||
async function processMovie(
|
async function processMovie(
|
||||||
@@ -117,7 +78,7 @@ async function processMovie(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasExistingMovieWatch(userId, title.id)) {
|
if (hasMovieWatch(userId, title.id)) {
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -164,11 +125,7 @@ async function processEpisode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find the specific episode in our DB
|
// Find the specific episode in our DB
|
||||||
const season = db
|
const season = findSeasonByTitleAndNumber(title.id, ep.seasonNumber);
|
||||||
.select()
|
|
||||||
.from(seasons)
|
|
||||||
.where(and(eq(seasons.titleId, title.id), eq(seasons.seasonNumber, ep.seasonNumber)))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!season) {
|
if (!season) {
|
||||||
result.failed++;
|
result.failed++;
|
||||||
@@ -176,11 +133,7 @@ async function processEpisode(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const episode = db
|
const episode = findEpisodeBySeasonAndNumber(season.id, ep.episodeNumber);
|
||||||
.select()
|
|
||||||
.from(episodes)
|
|
||||||
.where(and(eq(episodes.seasonId, season.id), eq(episodes.episodeNumber, ep.episodeNumber)))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!episode) {
|
if (!episode) {
|
||||||
result.failed++;
|
result.failed++;
|
||||||
@@ -188,7 +141,7 @@ async function processEpisode(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasExistingEpisodeWatch(userId, episode.id)) {
|
if (hasEpisodeWatch(userId, episode.id)) {
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -235,7 +188,7 @@ async function processWatchlistItem(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasExistingWatchlistStatus(userId, title.id)) {
|
if (hasTitleStatus(userId, title.id)) {
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -277,7 +230,7 @@ async function processRating(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasExistingRating(userId, title.id)) {
|
if (hasRating(userId, title.id)) {
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -294,27 +247,7 @@ async function processRating(
|
|||||||
// ─── Read Job Helper ─────────────────────────────────────────────────
|
// ─── Read Job Helper ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export function readImportJob(jobId: string, userId?: string): ImportJob {
|
export function readImportJob(jobId: string, userId?: string): ImportJob {
|
||||||
const row = db
|
const row = getImportJob(jobId);
|
||||||
.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();
|
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new Error(`Import job ${jobId} not found`);
|
throw new Error(`Import job ${jobId} not found`);
|
||||||
@@ -345,7 +278,7 @@ export function readImportJob(jobId: string, userId?: string): ImportJob {
|
|||||||
// ─── Job Processor ───────────────────────────────────────────────────
|
// ─── Job Processor ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function processImportJob(jobId: string): Promise<void> {
|
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) {
|
if (!row) {
|
||||||
throw new Error(`Import job ${jobId} not found`);
|
throw new Error(`Import job ${jobId} not found`);
|
||||||
@@ -397,23 +330,21 @@ export async function processImportJob(jobId: string): Promise<void> {
|
|||||||
|
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
result.warnings.push("No items to import with the selected options.");
|
result.warnings.push("No items to import with the selected options.");
|
||||||
db.update(importJobs)
|
updateImportJobProgress(jobId, {
|
||||||
.set({
|
status: "success",
|
||||||
status: "success",
|
finishedAt: new Date(),
|
||||||
finishedAt: new Date(),
|
totalItems: 0,
|
||||||
totalItems: 0,
|
warnings: JSON.stringify(result.warnings),
|
||||||
warnings: JSON.stringify(result.warnings),
|
});
|
||||||
})
|
|
||||||
.where(eq(importJobs.id, jobId))
|
|
||||||
.run();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set status to running + totalItems atomically
|
// Set status to running + totalItems atomically
|
||||||
db.update(importJobs)
|
updateImportJobProgress(jobId, {
|
||||||
.set({ status: "running", startedAt: new Date(), totalItems: total })
|
status: "running",
|
||||||
.where(eq(importJobs.id, jobId))
|
startedAt: new Date(),
|
||||||
.run();
|
totalItems: total,
|
||||||
|
});
|
||||||
|
|
||||||
log.info(`Starting ${data.source} import job ${jobId} for user ${row.userId}: ${total} items`);
|
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++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
// Check for cancellation periodically
|
// Check for cancellation periodically
|
||||||
if (i % progressInterval === 0) {
|
if (i % progressInterval === 0) {
|
||||||
const currentStatus = db
|
const currentStatus = getImportJobStatus(jobId);
|
||||||
.select({ status: importJobs.status })
|
|
||||||
.from(importJobs)
|
|
||||||
.where(eq(importJobs.id, jobId))
|
|
||||||
.get();
|
|
||||||
if (currentStatus?.status === "cancelled") {
|
if (currentStatus?.status === "cancelled") {
|
||||||
db.update(importJobs)
|
updateImportJobProgress(jobId, {
|
||||||
.set({
|
finishedAt: new Date(),
|
||||||
finishedAt: new Date(),
|
errors: JSON.stringify(result.errors),
|
||||||
errors: JSON.stringify(result.errors),
|
warnings: JSON.stringify(result.warnings),
|
||||||
warnings: JSON.stringify(result.warnings),
|
currentMessage: "Import cancelled",
|
||||||
currentMessage: "Import cancelled",
|
});
|
||||||
})
|
|
||||||
.where(eq(importJobs.id, jobId))
|
|
||||||
.run();
|
|
||||||
log.info(`Import job ${jobId} cancelled by user`);
|
log.info(`Import job ${jobId} cancelled by user`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -487,34 +411,28 @@ export async function processImportJob(jobId: string): Promise<void> {
|
|||||||
? (currentItem.showTitle ?? "Unknown")
|
? (currentItem.showTitle ?? "Unknown")
|
||||||
: "Unknown";
|
: "Unknown";
|
||||||
|
|
||||||
db.update(importJobs)
|
updateImportJobProgress(jobId, {
|
||||||
.set({
|
processedItems: i + 1,
|
||||||
processedItems: i + 1,
|
importedCount: result.imported,
|
||||||
importedCount: result.imported,
|
skippedCount: result.skipped,
|
||||||
skippedCount: result.skipped,
|
failedCount: result.failed,
|
||||||
failedCount: result.failed,
|
currentMessage: label,
|
||||||
currentMessage: label,
|
});
|
||||||
})
|
|
||||||
.where(eq(importJobs.id, jobId))
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success
|
// Success
|
||||||
db.update(importJobs)
|
updateImportJobProgress(jobId, {
|
||||||
.set({
|
status: "success",
|
||||||
status: "success",
|
finishedAt: new Date(),
|
||||||
finishedAt: new Date(),
|
processedItems: total,
|
||||||
processedItems: total,
|
importedCount: result.imported,
|
||||||
importedCount: result.imported,
|
skippedCount: result.skipped,
|
||||||
skippedCount: result.skipped,
|
failedCount: result.failed,
|
||||||
failedCount: result.failed,
|
errors: JSON.stringify(result.errors),
|
||||||
errors: JSON.stringify(result.errors),
|
warnings: JSON.stringify(result.warnings),
|
||||||
warnings: JSON.stringify(result.warnings),
|
currentMessage: "Import complete",
|
||||||
currentMessage: "Import complete",
|
});
|
||||||
})
|
|
||||||
.where(eq(importJobs.id, jobId))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
`Import job ${jobId} complete: ${result.imported} imported, ${result.skipped} skipped, ${result.failed} failed`,
|
`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) {
|
} catch (err) {
|
||||||
// Fatal error
|
// Fatal error
|
||||||
result.errors.push(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
|
result.errors.push(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
db.update(importJobs)
|
updateImportJobProgress(jobId, {
|
||||||
.set({
|
status: "error",
|
||||||
status: "error",
|
finishedAt: new Date(),
|
||||||
finishedAt: new Date(),
|
errors: JSON.stringify(result.errors),
|
||||||
errors: JSON.stringify(result.errors),
|
warnings: JSON.stringify(result.warnings),
|
||||||
warnings: JSON.stringify(result.warnings),
|
currentMessage: "Import failed",
|
||||||
currentMessage: "Import failed",
|
});
|
||||||
})
|
|
||||||
.where(eq(importJobs.id, jobId))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
log.error(`Import job ${jobId} failed:`, err);
|
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 { z } from "zod";
|
||||||
|
|
||||||
import { db } from "@sofa/db/client";
|
import {
|
||||||
import { and, eq, inArray } from "@sofa/db/helpers";
|
batchUpdateTvdbIds,
|
||||||
import { integrations, titles, userTitleStatus } from "@sofa/db/schema";
|
getRadarrMovies,
|
||||||
|
getSonarrShows,
|
||||||
|
resolveListIntegration,
|
||||||
|
} from "@sofa/db/queries/lists";
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
import { getTvExternalIds } from "@sofa/tmdb/client";
|
import { getTvExternalIds } from "@sofa/tmdb/client";
|
||||||
|
|
||||||
@@ -12,20 +15,7 @@ const log = createLogger("lists");
|
|||||||
export function resolveListToken(
|
export function resolveListToken(
|
||||||
token: string,
|
token: string,
|
||||||
): { userId: string; provider: "sonarr" | "radarr" } | null {
|
): { userId: string; provider: "sonarr" | "radarr" } | null {
|
||||||
const row = db
|
const row = resolveListIntegration(token);
|
||||||
.select({
|
|
||||||
userId: integrations.userId,
|
|
||||||
provider: integrations.provider,
|
|
||||||
})
|
|
||||||
.from(integrations)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(integrations.token, token),
|
|
||||||
eq(integrations.type, "list"),
|
|
||||||
eq(integrations.enabled, true),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return {
|
return {
|
||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
@@ -48,18 +38,7 @@ export function getRadarrList(
|
|||||||
userId: string,
|
userId: string,
|
||||||
statuses: Status[] = ["watchlist"],
|
statuses: Status[] = ["watchlist"],
|
||||||
): { Id: number }[] {
|
): { Id: number }[] {
|
||||||
const rows = db
|
const rows = getRadarrMovies(userId, statuses);
|
||||||
.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();
|
|
||||||
return rows.map((r) => ({ Id: r.tmdbId }));
|
return rows.map((r) => ({ Id: r.tmdbId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,23 +48,7 @@ export async function getSonarrList(
|
|||||||
userId: string,
|
userId: string,
|
||||||
statuses: Status[] = ["watchlist"],
|
statuses: Status[] = ["watchlist"],
|
||||||
): Promise<{ TvdbId: number; Title: string }[]> {
|
): Promise<{ TvdbId: number; Title: string }[]> {
|
||||||
const rows = db
|
const rows = getSonarrShows(userId, statuses);
|
||||||
.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();
|
|
||||||
|
|
||||||
// Resolve missing TVDB IDs in parallel instead of sequentially
|
// Resolve missing TVDB IDs in parallel instead of sequentially
|
||||||
const needsResolution = rows.filter((r) => r.tvdbId == null);
|
const needsResolution = rows.filter((r) => r.tvdbId == null);
|
||||||
@@ -101,15 +64,15 @@ export async function getSonarrList(
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// Batch update resolved IDs in a single transaction
|
// Batch update resolved IDs
|
||||||
db.transaction((tx) => {
|
const updates: { titleId: string; tvdbId: number }[] = [];
|
||||||
for (const { row, tvdbId } of resolved) {
|
for (const { row, tvdbId } of resolved) {
|
||||||
if (tvdbId != null) {
|
if (tvdbId != null) {
|
||||||
row.tvdbId = tvdbId;
|
row.tvdbId = tvdbId;
|
||||||
tx.update(titles).set({ tvdbId }).where(eq(titles.id, row.id)).run();
|
updates.push({ titleId: row.id, tvdbId });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
batchUpdateTvdbIds(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
|
|||||||
+151
-501
@@ -5,19 +5,34 @@ import type {
|
|||||||
ResolvedTitle,
|
ResolvedTitle,
|
||||||
Season,
|
Season,
|
||||||
} from "@sofa/api/schemas";
|
} from "@sofa/api/schemas";
|
||||||
import { db } from "@sofa/db/client";
|
|
||||||
import { and, eq, inArray, isNotNull, sql } from "@sofa/db/helpers";
|
|
||||||
import {
|
import {
|
||||||
availabilityOffers,
|
batchUpsertEpisodes,
|
||||||
episodes,
|
ensureBrowseTitlesTransaction,
|
||||||
genres,
|
getAvailabilityOffersForTitle,
|
||||||
seasons,
|
getEpisodesBySeasonIds,
|
||||||
titleGenres,
|
getEpisodesNeedingStillHash,
|
||||||
titleRecommendations,
|
getExistingSeasonInfo,
|
||||||
titles,
|
getOldEpisodeStills,
|
||||||
} from "@sofa/db/schema";
|
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 { createLogger } from "@sofa/logger";
|
||||||
import type { TmdbGenre, TmdbMovieDetails, TmdbTvDetails, TmdbVideo } from "@sofa/tmdb/client";
|
import type { TmdbMovieDetails, TmdbTvDetails, TmdbVideo } from "@sofa/tmdb/client";
|
||||||
import {
|
import {
|
||||||
getMovieDetails,
|
getMovieDetails,
|
||||||
getRecommendations,
|
getRecommendations,
|
||||||
@@ -47,39 +62,6 @@ import {
|
|||||||
|
|
||||||
const log = createLogger("metadata");
|
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(
|
export function updateTitleWithArtInvalidation(
|
||||||
title: Pick<
|
title: Pick<
|
||||||
typeof titles.$inferSelect,
|
typeof titles.$inferSelect,
|
||||||
@@ -97,23 +79,20 @@ export function updateTitleWithArtInvalidation(
|
|||||||
const posterPathChanged = nextPosterPath !== title.posterPath;
|
const posterPathChanged = nextPosterPath !== title.posterPath;
|
||||||
const backdropPathChanged = nextBackdropPath !== title.backdropPath;
|
const backdropPathChanged = nextBackdropPath !== title.backdropPath;
|
||||||
|
|
||||||
db.update(titles)
|
updateTitleFields(title.id, {
|
||||||
.set({
|
...values,
|
||||||
...values,
|
...(posterPathChanged
|
||||||
...(posterPathChanged
|
? {
|
||||||
? {
|
posterThumbHash: null,
|
||||||
posterThumbHash: null,
|
colorPalette: null,
|
||||||
colorPalette: null,
|
}
|
||||||
}
|
: {}),
|
||||||
: {}),
|
...(backdropPathChanged
|
||||||
...(backdropPathChanged
|
? {
|
||||||
? {
|
backdropThumbHash: null,
|
||||||
backdropThumbHash: null,
|
}
|
||||||
}
|
: {}),
|
||||||
: {}),
|
});
|
||||||
})
|
|
||||||
.where(eq(titles.id, title.id))
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
@@ -173,11 +152,7 @@ export function getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv"): I
|
|||||||
async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
||||||
log.debug(`Importing ${type} TMDB ${tmdbId}`);
|
log.debug(`Importing ${type} TMDB ${tmdbId}`);
|
||||||
|
|
||||||
const existing = db
|
const existing = getTitleByTmdbIdAndType(tmdbId, type);
|
||||||
.select()
|
|
||||||
.from(titles)
|
|
||||||
.where(and(eq(titles.tmdbId, tmdbId), eq(titles.type, type)))
|
|
||||||
.get();
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Shell title — upgrade to full import
|
// Shell title — upgrade to full import
|
||||||
if (!existing.lastFetchedAt) {
|
if (!existing.lastFetchedAt) {
|
||||||
@@ -200,9 +175,9 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
|||||||
runtimeMinutes: movie.runtime ?? null,
|
runtimeMinutes: movie.runtime ?? null,
|
||||||
lastFetchedAt: new Date(),
|
lastFetchedAt: new Date(),
|
||||||
});
|
});
|
||||||
upsertGenres(existing.id, movie.genres ?? []);
|
upsertGenresTransaction(existing.id, movie.genres ?? []);
|
||||||
fireAndForgetEnrichment(existing.id, movie.poster_path, movie.backdrop_path, "movie");
|
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
|
// TV shell — fetch details + children
|
||||||
@@ -218,26 +193,20 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
|
|||||||
originalLanguage: show.original_language ?? null,
|
originalLanguage: show.original_language ?? null,
|
||||||
lastFetchedAt: new Date(),
|
lastFetchedAt: new Date(),
|
||||||
});
|
});
|
||||||
upsertGenres(existing.id, show.genres ?? []);
|
upsertGenresTransaction(existing.id, show.genres ?? []);
|
||||||
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
|
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
|
||||||
fireAndForgetEnrichment(existing.id, show.poster_path, show.backdrop_path, "tv");
|
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)
|
// For fully-fetched TV shows, check if seasons are missing (e.g. prior failure)
|
||||||
if (existing.type === "tv") {
|
if (existing.type === "tv") {
|
||||||
const hasSeason = db
|
if (!hasSeasonForTitle(existing.id)) {
|
||||||
.select({ id: seasons.id })
|
|
||||||
.from(seasons)
|
|
||||||
.where(eq(seasons.titleId, existing.id))
|
|
||||||
.limit(1)
|
|
||||||
.get();
|
|
||||||
if (!hasSeason) {
|
|
||||||
const show = await getTvDetails(tmdbId);
|
const show = await getTvDetails(tmdbId);
|
||||||
upsertGenres(existing.id, show.genres ?? []);
|
upsertGenresTransaction(existing.id, show.genres ?? []);
|
||||||
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
|
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
|
||||||
fireAndForgetEnrichment(existing.id, show.poster_path, show.backdrop_path, "tv");
|
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") {
|
if (type === "movie") {
|
||||||
const movie = await getMovieDetails(tmdbId);
|
const movie = await getMovieDetails(tmdbId);
|
||||||
const row = upsertTitle(
|
const row = insertTitleReturning({
|
||||||
{
|
tmdbId: movie.id,
|
||||||
tmdbId: movie.id,
|
type: "movie",
|
||||||
type: "movie",
|
title: movie.title ?? "",
|
||||||
title: movie.title ?? "",
|
originalTitle: movie.original_title,
|
||||||
originalTitle: movie.original_title,
|
overview: movie.overview,
|
||||||
overview: movie.overview,
|
releaseDate: movie.release_date || null,
|
||||||
releaseDate: movie.release_date || null,
|
posterPath: movie.poster_path,
|
||||||
posterPath: movie.poster_path,
|
backdropPath: movie.backdrop_path,
|
||||||
backdropPath: movie.backdrop_path,
|
popularity: movie.popularity,
|
||||||
popularity: movie.popularity,
|
voteAverage: movie.vote_average,
|
||||||
voteAverage: movie.vote_average,
|
voteCount: movie.vote_count,
|
||||||
voteCount: movie.vote_count,
|
status: movie.status,
|
||||||
status: movie.status,
|
contentRating: extractMovieContentRating(movie),
|
||||||
contentRating: extractMovieContentRating(movie),
|
imdbId: movie.imdb_id ?? null,
|
||||||
imdbId: movie.imdb_id ?? null,
|
originalLanguage: movie.original_language ?? null,
|
||||||
originalLanguage: movie.original_language ?? null,
|
runtimeMinutes: movie.runtime ?? null,
|
||||||
runtimeMinutes: movie.runtime ?? null,
|
lastFetchedAt: now,
|
||||||
lastFetchedAt: now,
|
});
|
||||||
},
|
|
||||||
tmdbId,
|
|
||||||
);
|
|
||||||
if (!row) return undefined;
|
if (!row) return undefined;
|
||||||
upsertGenres(row.id, movie.genres ?? []);
|
upsertGenresTransaction(row.id, movie.genres ?? []);
|
||||||
fireAndForgetEnrichment(row.id, movie.poster_path, movie.backdrop_path, "movie");
|
fireAndForgetEnrichment(row.id, movie.poster_path, movie.backdrop_path, "movie");
|
||||||
log.info(`Imported movie "${movie.title}" (TMDB ${tmdbId})`);
|
log.info(`Imported movie "${movie.title}" (TMDB ${tmdbId})`);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
const show = await getTvDetails(tmdbId);
|
const show = await getTvDetails(tmdbId);
|
||||||
const row = upsertTitle(
|
const row = insertTitleReturning({
|
||||||
{
|
tmdbId: show.id,
|
||||||
tmdbId: show.id,
|
tvdbId: show.external_ids?.tvdb_id ?? null,
|
||||||
tvdbId: show.external_ids?.tvdb_id ?? null,
|
type: "tv",
|
||||||
type: "tv",
|
title: show.name ?? "",
|
||||||
title: show.name ?? "",
|
originalTitle: show.original_name,
|
||||||
originalTitle: show.original_name,
|
overview: show.overview,
|
||||||
overview: show.overview,
|
firstAirDate: show.first_air_date || null,
|
||||||
firstAirDate: show.first_air_date || null,
|
posterPath: show.poster_path,
|
||||||
posterPath: show.poster_path,
|
backdropPath: show.backdrop_path,
|
||||||
backdropPath: show.backdrop_path,
|
popularity: show.popularity,
|
||||||
popularity: show.popularity,
|
voteAverage: show.vote_average,
|
||||||
voteAverage: show.vote_average,
|
voteCount: show.vote_count,
|
||||||
voteCount: show.vote_count,
|
status: show.status,
|
||||||
status: show.status,
|
contentRating: extractTvContentRating(show),
|
||||||
contentRating: extractTvContentRating(show),
|
imdbId: show.external_ids?.imdb_id ?? null,
|
||||||
imdbId: show.external_ids?.imdb_id ?? null,
|
originalLanguage: show.original_language ?? null,
|
||||||
originalLanguage: show.original_language ?? null,
|
lastFetchedAt: now,
|
||||||
lastFetchedAt: now,
|
});
|
||||||
},
|
|
||||||
tmdbId,
|
|
||||||
);
|
|
||||||
if (!row) return undefined;
|
if (!row) return undefined;
|
||||||
upsertGenres(row.id, show.genres ?? []);
|
upsertGenresTransaction(row.id, show.genres ?? []);
|
||||||
|
|
||||||
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
|
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
|
||||||
fireAndForgetEnrichment(row.id, show.poster_path, show.backdrop_path, "tv");
|
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) {
|
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;
|
if (!title) return null;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -334,7 +297,7 @@ export async function refreshTitle(titleId: string) {
|
|||||||
runtimeMinutes: movie.runtime ?? null,
|
runtimeMinutes: movie.runtime ?? null,
|
||||||
lastFetchedAt: now,
|
lastFetchedAt: now,
|
||||||
});
|
});
|
||||||
upsertGenres(titleId, movie.genres ?? []);
|
upsertGenresTransaction(titleId, movie.genres ?? []);
|
||||||
} else {
|
} else {
|
||||||
const show = await getTvDetails(title.tmdbId);
|
const show = await getTvDetails(title.tmdbId);
|
||||||
updateTitleWithArtInvalidation(title, {
|
updateTitleWithArtInvalidation(title, {
|
||||||
@@ -354,11 +317,11 @@ export async function refreshTitle(titleId: string) {
|
|||||||
originalLanguage: show.original_language ?? null,
|
originalLanguage: show.original_language ?? null,
|
||||||
lastFetchedAt: now,
|
lastFetchedAt: now,
|
||||||
});
|
});
|
||||||
upsertGenres(titleId, show.genres ?? []);
|
upsertGenresTransaction(titleId, show.genres ?? []);
|
||||||
await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons);
|
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) {
|
if (updated) {
|
||||||
syncTitleArt(
|
syncTitleArt(
|
||||||
updated.id,
|
updated.id,
|
||||||
@@ -391,109 +354,53 @@ export async function refreshTvChildren(titleId: string, tmdbId: number, numberO
|
|||||||
|
|
||||||
const seasonData = result.value;
|
const seasonData = result.value;
|
||||||
|
|
||||||
const existingSeason = db
|
const existingSeason = getExistingSeasonInfo(titleId, sn);
|
||||||
.select({
|
|
||||||
id: seasons.id,
|
|
||||||
posterPath: seasons.posterPath,
|
|
||||||
})
|
|
||||||
.from(seasons)
|
|
||||||
.where(and(eq(seasons.titleId, titleId), eq(seasons.seasonNumber, sn)))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
const seasonRow = db
|
const seasonRow = upsertSeasonReturning({
|
||||||
.insert(seasons)
|
titleId,
|
||||||
.values({
|
seasonNumber: seasonData.season_number,
|
||||||
titleId,
|
name: seasonData.name ?? null,
|
||||||
seasonNumber: seasonData.season_number,
|
overview: seasonData.overview ?? null,
|
||||||
name: seasonData.name,
|
posterPath: seasonData.poster_path ?? null,
|
||||||
overview: seasonData.overview,
|
airDate: seasonData.air_date ?? null,
|
||||||
posterPath: seasonData.poster_path,
|
lastFetchedAt: now,
|
||||||
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();
|
|
||||||
|
|
||||||
// Snapshot existing episode still paths before upsert so we can detect changes
|
// Snapshot existing episode still paths before upsert so we can detect changes
|
||||||
const oldEpStills = new Map(
|
const oldEpStills = getOldEpisodeStills(existingSeason?.id ?? seasonRow.id);
|
||||||
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),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Batch all episode upserts in a single transaction per season
|
// Batch all episode upserts in a single transaction per season
|
||||||
const eps = seasonData.episodes ?? [];
|
const eps = seasonData.episodes ?? [];
|
||||||
if (eps.length > 0) {
|
batchUpsertEpisodes(
|
||||||
db.transaction((tx) => {
|
seasonRow.id,
|
||||||
for (const ep of eps) {
|
eps.map((ep) => ({
|
||||||
tx.insert(episodes)
|
episode_number: ep.episode_number,
|
||||||
.values({
|
name: ep.name ?? null,
|
||||||
seasonId: seasonRow.id,
|
overview: ep.overview ?? null,
|
||||||
episodeNumber: ep.episode_number,
|
still_path: ep.still_path ?? null,
|
||||||
name: ep.name,
|
air_date: ep.air_date ?? null,
|
||||||
overview: ep.overview,
|
runtime: ep.runtime,
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear stale hashes when image paths change during the upsert.
|
// Clear stale hashes when image paths change during the upsert.
|
||||||
// Full hash (re)generation is handled by syncTitleArt() after
|
// Full hash (re)generation is handled by syncTitleArt() after
|
||||||
// cache warming, so we only need to null out stale values here.
|
// cache warming, so we only need to null out stale values here.
|
||||||
if ((existingSeason?.posterPath ?? null) !== (seasonData.poster_path ?? null)) {
|
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
|
const seasonEps = getSeasonEpisodesWithHashes(seasonRow.id);
|
||||||
.select({
|
|
||||||
id: episodes.id,
|
|
||||||
episodeNumber: episodes.episodeNumber,
|
|
||||||
stillPath: episodes.stillPath,
|
|
||||||
stillThumbHash: episodes.stillThumbHash,
|
|
||||||
})
|
|
||||||
.from(episodes)
|
|
||||||
.where(eq(episodes.seasonId, seasonRow.id))
|
|
||||||
.all();
|
|
||||||
for (const ep of seasonEps) {
|
for (const ep of seasonEps) {
|
||||||
const oldStill = oldEpStills.get(ep.episodeNumber);
|
const oldStill = oldEpStills.get(ep.episodeNumber);
|
||||||
if (oldStill !== ep.stillPath && ep.stillThumbHash) {
|
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) {
|
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;
|
if (!title) return;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -576,146 +483,23 @@ export async function refreshRecommendations(titleId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch prefetch existing titles (1 query)
|
const titleIdMap = upsertRecommendationsTransaction(
|
||||||
const tmdbIds = [...uniqueTitles.keys()];
|
titleId,
|
||||||
const existingTitles = db
|
uniqueTitles,
|
||||||
.select({
|
allItems.map((item) => ({
|
||||||
id: titles.id,
|
tmdbId: item.result.id,
|
||||||
tmdbId: titles.tmdbId,
|
source: item.source,
|
||||||
posterPath: titles.posterPath,
|
rank: item.rank,
|
||||||
backdropPath: titles.backdropPath,
|
})),
|
||||||
posterThumbHash: titles.posterThumbHash,
|
now,
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fire-and-forget thumbhash generation for recommendation titles missing one
|
// Fire-and-forget thumbhash generation for recommendation titles missing one
|
||||||
const recTitleIds = [
|
const recTitleIds = [
|
||||||
...new Set(allItems.map((i) => titleIdMap.get(i.result.id)).filter(Boolean)),
|
...new Set(allItems.map((i) => titleIdMap.get(i.result.id)).filter(Boolean)),
|
||||||
] as string[];
|
] as string[];
|
||||||
if (recTitleIds.length > 0) {
|
if (recTitleIds.length > 0) {
|
||||||
const needingHash = db
|
const needingHash = getTitlesNeedingPosterHash(recTitleIds);
|
||||||
.select({ id: titles.id, posterPath: titles.posterPath })
|
|
||||||
.from(titles)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(titles.id, recTitleIds),
|
|
||||||
isNotNull(titles.posterPath),
|
|
||||||
sql`${titles.posterThumbHash} IS NULL`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.all();
|
|
||||||
for (const t of needingHash) {
|
for (const t of needingHash) {
|
||||||
generateTitlePosterThumbHash(t.id, t.posterPath).catch((err) =>
|
generateTitlePosterThumbHash(t.id, t.posterPath).catch((err) =>
|
||||||
log.debug("Recommendation poster thumbhash failed:", 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. */
|
/** Fetch seasons from the DB, building the Season[] structure. */
|
||||||
function fetchSeasonsFromDb(titleId: string): Season[] {
|
function fetchSeasonsFromDb(titleId: string): Season[] {
|
||||||
const seasonRows = db
|
const seasonRows = getSeasonsForTitle(titleId);
|
||||||
.select()
|
|
||||||
.from(seasons)
|
|
||||||
.where(eq(seasons.titleId, titleId))
|
|
||||||
.orderBy(seasons.seasonNumber)
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (seasonRows.length === 0) return [];
|
if (seasonRows.length === 0) return [];
|
||||||
|
|
||||||
const seasonIds = seasonRows.map((s) => s.id);
|
const seasonIds = seasonRows.map((s) => s.id);
|
||||||
const allEps = db
|
const allEps = getEpisodesBySeasonIds(seasonIds);
|
||||||
.select()
|
|
||||||
.from(episodes)
|
|
||||||
.where(inArray(episodes.seasonId, seasonIds))
|
|
||||||
.orderBy(episodes.seasonId, episodes.episodeNumber)
|
|
||||||
.all();
|
|
||||||
|
|
||||||
const epsBySeason = new Map<string, Episode[]>();
|
const epsBySeason = new Map<string, Episode[]>();
|
||||||
for (const ep of allEps) {
|
for (const ep of allEps) {
|
||||||
@@ -772,7 +546,7 @@ function fetchSeasonsFromDb(titleId: string): Season[] {
|
|||||||
* Returns the hydrated seasons data.
|
* Returns the hydrated seasons data.
|
||||||
*/
|
*/
|
||||||
export async function ensureTvHydrated(titleId: string): Promise<Season[]> {
|
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 [];
|
if (!title || title.type !== "tv") return [];
|
||||||
|
|
||||||
const { tmdbId } = title;
|
const { tmdbId } = title;
|
||||||
@@ -791,7 +565,7 @@ export async function ensureTvHydrated(titleId: string): Promise<Season[]> {
|
|||||||
originalLanguage: show.original_language ?? null,
|
originalLanguage: show.original_language ?? null,
|
||||||
lastFetchedAt: new Date(),
|
lastFetchedAt: new Date(),
|
||||||
});
|
});
|
||||||
upsertGenres(titleId, show.genres ?? []);
|
upsertGenresTransaction(titleId, show.genres ?? []);
|
||||||
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
|
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.debug(`Failed to hydrate shell TV title ${titleId}:`, 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
|
// Recommendations are loaded separately (Suspense), so check here
|
||||||
const hasRecommendations =
|
if (!hasRecommendationsForTitle(titleId)) {
|
||||||
db
|
|
||||||
.select({ titleId: titleRecommendations.titleId })
|
|
||||||
.from(titleRecommendations)
|
|
||||||
.where(eq(titleRecommendations.titleId, titleId))
|
|
||||||
.limit(1)
|
|
||||||
.get() != null;
|
|
||||||
if (!hasRecommendations) {
|
|
||||||
tasks.push(
|
tasks.push(
|
||||||
refreshRecommendations(titleId).catch((err) =>
|
refreshRecommendations(titleId).catch((err) =>
|
||||||
log.debug("Recommendations enrichment failed:", err),
|
log.debug("Recommendations enrichment failed:", err),
|
||||||
@@ -888,18 +655,13 @@ async function ensureEnriched(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readAvailability(titleId: string, titleName: string): AvailabilityOffer[] {
|
function readAvailability(titleId: string, titleName: string): AvailabilityOffer[] {
|
||||||
return db
|
return getAvailabilityOffersForTitle(titleId).map((a) => ({
|
||||||
.select()
|
providerId: a.providerId,
|
||||||
.from(availabilityOffers)
|
providerName: a.providerName,
|
||||||
.where(eq(availabilityOffers.titleId, titleId))
|
logoPath: tmdbImageUrl(a.logoPath, "logos"),
|
||||||
.all()
|
offerType: a.offerType,
|
||||||
.map((a) => ({
|
watchUrl: generateProviderUrl(a.providerId, titleName),
|
||||||
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<{
|
export async function getOrFetchTitle(id: string): Promise<{
|
||||||
@@ -908,7 +670,7 @@ export async function getOrFetchTitle(id: string): Promise<{
|
|||||||
availability: AvailabilityOffer[];
|
availability: AvailabilityOffer[];
|
||||||
cast: CastMember[];
|
cast: CastMember[];
|
||||||
} | null> {
|
} | null> {
|
||||||
let title = db.select().from(titles).where(eq(titles.id, id)).get();
|
let title = getTitleById(id);
|
||||||
if (!title) return null;
|
if (!title) return null;
|
||||||
|
|
||||||
// If this is a shell movie title, fetch full details now (movies are fast)
|
// 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,
|
runtimeMinutes: movie.runtime ?? null,
|
||||||
lastFetchedAt: new Date(),
|
lastFetchedAt: new Date(),
|
||||||
});
|
});
|
||||||
upsertGenres(id, movie.genres ?? []);
|
upsertGenresTransaction(id, movie.genres ?? []);
|
||||||
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
|
title = getTitleById(id) ?? title;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.debug(`Failed to hydrate shell movie title ${id}:`, 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
|
// Re-read only what was missing
|
||||||
if (cast.length === 0) cast = getCastForTitle(id);
|
if (cast.length === 0) cast = getCastForTitle(id);
|
||||||
if (availability.length === 0) availability = readAvailability(title.id, title.title);
|
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 palette = parseColorPalette(title.colorPalette);
|
||||||
|
|
||||||
const titleGenreRows = db
|
const titleGenreRows = getTitleGenres(id);
|
||||||
.select({ name: genres.name })
|
|
||||||
.from(titleGenres)
|
|
||||||
.innerJoin(genres, eq(titleGenres.genreId, genres.id))
|
|
||||||
.where(eq(titleGenres.titleId, id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
const resolvedTitle: ResolvedTitle = {
|
const resolvedTitle: ResolvedTitle = {
|
||||||
id: title.id,
|
id: title.id,
|
||||||
@@ -1038,13 +795,13 @@ export function pickBestTrailer(videos: TmdbVideo[]): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshTrailer(titleId: string) {
|
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;
|
if (!title) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await getVideos(title.tmdbId, title.type);
|
const response = await getVideos(title.tmdbId, title.type);
|
||||||
const key = pickBestTrailer(response.results ?? []);
|
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"}`);
|
log.debug(`Trailer for "${title.title}": ${key ? `YouTube ${key}` : "none found"}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.debug(`Failed to fetch trailer for title ${titleId}:`, 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) {
|
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>[] = [];
|
const hashTasks: Promise<unknown>[] = [];
|
||||||
|
|
||||||
@@ -1064,20 +821,7 @@ async function generateMissingTvChildThumbHashes(titleId: string) {
|
|||||||
|
|
||||||
const seasonIds = titleSeasons.map((s) => s.id);
|
const seasonIds = titleSeasons.map((s) => s.id);
|
||||||
if (seasonIds.length > 0) {
|
if (seasonIds.length > 0) {
|
||||||
const epsNeedingHash = db
|
const epsNeedingHash = getEpisodesNeedingStillHash(seasonIds);
|
||||||
.select({
|
|
||||||
id: episodes.id,
|
|
||||||
stillPath: episodes.stillPath,
|
|
||||||
})
|
|
||||||
.from(episodes)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(episodes.seasonId, seasonIds),
|
|
||||||
isNotNull(episodes.stillPath),
|
|
||||||
sql`${episodes.stillThumbHash} IS NULL`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.all();
|
|
||||||
for (const ep of epsNeedingHash) {
|
for (const ep of epsNeedingHash) {
|
||||||
hashTasks.push(generateEpisodeThumbHash(ep.id, ep.stillPath));
|
hashTasks.push(generateEpisodeThumbHash(ep.id, ep.stillPath));
|
||||||
}
|
}
|
||||||
@@ -1141,10 +885,6 @@ interface BrowseTitleInput {
|
|||||||
voteCount?: number | null;
|
voteCount?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function browseTitleKey(tmdbId: number, type: string): string {
|
|
||||||
return `${tmdbId}-${type}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure every browse/search result has a local title row.
|
* Ensure every browse/search result has a local title row.
|
||||||
* Inserts shell titles (`lastFetchedAt = null`) for new (tmdbId, type) pairs.
|
* Inserts shell titles (`lastFetchedAt = null`) for new (tmdbId, type) pairs.
|
||||||
@@ -1153,95 +893,5 @@ function browseTitleKey(tmdbId: number, type: string): string {
|
|||||||
export function ensureBrowseTitlesExist(
|
export function ensureBrowseTitlesExist(
|
||||||
items: BrowseTitleInput[],
|
items: BrowseTitleInput[],
|
||||||
): Map<string, { id: string; posterThumbHash: string | null }> {
|
): Map<string, { id: string; posterThumbHash: string | null }> {
|
||||||
if (items.length === 0) return new Map();
|
return ensureBrowseTitlesTransaction(items);
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-178
@@ -1,7 +1,16 @@
|
|||||||
import type { PersonCredit, ResolvedPerson } from "@sofa/api/schemas";
|
import type { PersonCredit, ResolvedPerson } from "@sofa/api/schemas";
|
||||||
import { db } from "@sofa/db/client";
|
import {
|
||||||
import { eq, inArray } from "@sofa/db/helpers";
|
batchInsertShellTitlesTransaction,
|
||||||
import { personFilmography, persons, titles } from "@sofa/db/schema";
|
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 { createLogger } from "@sofa/logger";
|
||||||
import { getPersonCombinedCredits, getPersonDetails } from "@sofa/tmdb/client";
|
import { getPersonCombinedCredits, getPersonDetails } from "@sofa/tmdb/client";
|
||||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
@@ -12,7 +21,10 @@ const log = createLogger("person");
|
|||||||
const FILMOGRAPHY_STALE_MS = 30 * 24 * 60 * 60 * 1000;
|
const FILMOGRAPHY_STALE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
async function ensureProfileThumbHash(
|
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.profilePath) {
|
||||||
if (person.profileThumbHash) {
|
if (person.profileThumbHash) {
|
||||||
@@ -29,28 +41,25 @@ async function ensureProfileThumbHash(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrFetchPerson(personId: string): Promise<ResolvedPerson | null> {
|
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;
|
if (!person) return null;
|
||||||
|
|
||||||
// Shell record — lazily hydrate from TMDB
|
// Shell record — lazily hydrate from TMDB
|
||||||
if (!person.lastFetchedAt) {
|
if (!person.lastFetchedAt) {
|
||||||
try {
|
try {
|
||||||
const details = await getPersonDetails(person.tmdbId);
|
const details = await getPersonDetails(person.tmdbId);
|
||||||
db.update(persons)
|
updatePerson(personId, {
|
||||||
.set({
|
name: details.name || person.name,
|
||||||
name: details.name || person.name,
|
biography: details.biography || null,
|
||||||
biography: details.biography || null,
|
birthday: details.birthday,
|
||||||
birthday: details.birthday,
|
deathday: details.deathday,
|
||||||
deathday: details.deathday,
|
placeOfBirth: details.place_of_birth,
|
||||||
placeOfBirth: details.place_of_birth,
|
profilePath: details.profile_path,
|
||||||
profilePath: details.profile_path,
|
knownForDepartment: details.known_for_department,
|
||||||
knownForDepartment: details.known_for_department,
|
popularity: details.popularity,
|
||||||
popularity: details.popularity,
|
imdbId: details.imdb_id,
|
||||||
imdbId: details.imdb_id,
|
lastFetchedAt: new Date(),
|
||||||
lastFetchedAt: new Date(),
|
});
|
||||||
})
|
|
||||||
.where(eq(persons.id, personId))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
const newProfilePath = details.profile_path ?? null;
|
const newProfilePath = details.profile_path ?? null;
|
||||||
const pathChanged = newProfilePath !== person.profilePath;
|
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> {
|
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) {
|
if (existing) {
|
||||||
return getOrFetchPerson(existing.id);
|
return getOrFetchPerson(existing.id);
|
||||||
@@ -111,26 +120,21 @@ export async function getOrFetchPersonByTmdbId(tmdbId: number): Promise<Resolved
|
|||||||
// Create from TMDB
|
// Create from TMDB
|
||||||
try {
|
try {
|
||||||
const details = await getPersonDetails(tmdbId);
|
const details = await getPersonDetails(tmdbId);
|
||||||
const row = db
|
const row = insertPersonReturning({
|
||||||
.insert(persons)
|
tmdbId,
|
||||||
.values({
|
name: details.name ?? "",
|
||||||
tmdbId,
|
biography: details.biography || null,
|
||||||
name: details.name ?? "",
|
birthday: details.birthday ?? null,
|
||||||
biography: details.biography || null,
|
deathday: details.deathday ?? null,
|
||||||
birthday: details.birthday ?? null,
|
placeOfBirth: details.place_of_birth ?? null,
|
||||||
deathday: details.deathday ?? null,
|
profilePath: details.profile_path ?? null,
|
||||||
placeOfBirth: details.place_of_birth ?? null,
|
knownForDepartment: details.known_for_department ?? null,
|
||||||
profilePath: details.profile_path ?? null,
|
popularity: details.popularity,
|
||||||
knownForDepartment: details.known_for_department ?? null,
|
imdbId: details.imdb_id ?? null,
|
||||||
popularity: details.popularity,
|
lastFetchedAt: new Date(),
|
||||||
imdbId: details.imdb_id ?? null,
|
});
|
||||||
lastFetchedAt: new Date(),
|
|
||||||
})
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.returning()
|
|
||||||
.get();
|
|
||||||
|
|
||||||
const person = row ?? db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get();
|
const person = row ?? getPersonByTmdbId(tmdbId);
|
||||||
if (!person) return null;
|
if (!person) return null;
|
||||||
|
|
||||||
const profileThumbHash = await ensureProfileThumbHash(person);
|
const profileThumbHash = await ensureProfileThumbHash(person);
|
||||||
@@ -155,26 +159,7 @@ export async function getOrFetchPersonByTmdbId(tmdbId: number): Promise<Resolved
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getLocalFilmography(personId: string): PersonCredit[] {
|
export function getLocalFilmography(personId: string): PersonCredit[] {
|
||||||
const rows = db
|
const rows = getPersonFilmographyJoined(personId);
|
||||||
.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();
|
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
titleId: r.titleId,
|
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);
|
const credits = await getPersonCombinedCredits(person.tmdbId);
|
||||||
|
|
||||||
// Schema types combined credits as movie-only; TV entries also carry
|
// 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 allEntries = [...validCast, ...validCrew];
|
||||||
const tmdbIds = [...new Set(allEntries.map((c) => c.id))];
|
const tmdbIds = [...new Set(allEntries.map((c) => c.id))];
|
||||||
|
|
||||||
const existingTitles =
|
const existingTitles = getExistingTitlesByTmdbIds(tmdbIds);
|
||||||
tmdbIds.length > 0
|
|
||||||
? db
|
|
||||||
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
|
||||||
.from(titles)
|
|
||||||
.where(inArray(titles.tmdbId, tmdbIds))
|
|
||||||
.all()
|
|
||||||
: [];
|
|
||||||
const titleIdMap = new Map<number, string>(
|
const titleIdMap = new Map<number, string>(
|
||||||
existingTitles.map((title) => [title.tmdbId, title.id]),
|
existingTitles.map((title) => [title.tmdbId, title.id]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const newEntries = allEntries.filter((entry) => !titleIdMap.has(entry.id));
|
const newEntries = allEntries.filter((entry) => !titleIdMap.has(entry.id));
|
||||||
if (newEntries.length > 0) {
|
if (newEntries.length > 0) {
|
||||||
const insertedTmdbIds = new Set<number>();
|
const shellEntries = newEntries
|
||||||
db.transaction((tx) => {
|
.filter((entry, index, arr) => arr.findIndex((e) => e.id === entry.id) === index)
|
||||||
for (const entry of newEntries) {
|
.map((entry) => ({
|
||||||
if (insertedTmdbIds.has(entry.id)) continue;
|
tmdbId: entry.id,
|
||||||
insertedTmdbIds.add(entry.id);
|
mediaType: entry.media_type as "movie" | "tv",
|
||||||
const row = tx
|
title: entry.title ?? entry.name ?? "Unknown",
|
||||||
.insert(titles)
|
overview: entry.overview,
|
||||||
.values({
|
releaseDate: entry.release_date,
|
||||||
tmdbId: entry.id,
|
firstAirDate: entry.first_air_date,
|
||||||
type: entry.media_type as "movie" | "tv",
|
posterPath: entry.poster_path,
|
||||||
title: entry.title ?? entry.name ?? "Unknown",
|
backdropPath: entry.backdrop_path,
|
||||||
overview: entry.overview,
|
popularity: entry.popularity,
|
||||||
releaseDate: entry.release_date,
|
voteAverage: entry.vote_average,
|
||||||
firstAirDate: entry.first_air_date,
|
voteCount: entry.vote_count,
|
||||||
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 stillMissing = [...insertedTmdbIds].filter((tmdbId) => !titleIdMap.has(tmdbId));
|
const updatedMap = batchInsertShellTitlesTransaction(shellEntries, titleIdMap);
|
||||||
if (stillMissing.length > 0) {
|
for (const [tmdbId, id] of updatedMap) {
|
||||||
const fallbacks = db
|
titleIdMap.set(tmdbId, id);
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,22 +264,11 @@ async function syncPersonFilmography(person: Pick<typeof persons.$inferSelect, "
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
db.transaction((tx) => {
|
replaceFilmographyTransaction(person.id, nextRows, now);
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchFullFilmography(personId: string): Promise<PersonCredit[]> {
|
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 [];
|
if (!person) return [];
|
||||||
|
|
||||||
const localFilmography = getLocalFilmography(personId);
|
const localFilmography = getLocalFilmography(personId);
|
||||||
@@ -362,63 +312,5 @@ interface BrowsePersonInput {
|
|||||||
* Returns a map of tmdbId → internal UUID.
|
* Returns a map of tmdbId → internal UUID.
|
||||||
*/
|
*/
|
||||||
export function ensureBrowsePersonsExist(items: BrowsePersonInput[]): Map<number, string> {
|
export function ensureBrowsePersonsExist(items: BrowsePersonInput[]): Map<number, string> {
|
||||||
if (items.length === 0) return new Map();
|
return ensureBrowsePersonsTransaction(items);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
import { db } from "@sofa/db/client";
|
import {
|
||||||
import { count, eq } from "@sofa/db/helpers";
|
getSettingValue,
|
||||||
import { appSettings, user } from "@sofa/db/schema";
|
getUserCount as queryGetUserCount,
|
||||||
|
upsertSetting,
|
||||||
|
} from "@sofa/db/queries/settings";
|
||||||
|
|
||||||
export function getSetting(key: string): string | null {
|
export function getSetting(key: string): string | null {
|
||||||
const row = db.select().from(appSettings).where(eq(appSettings.key, key)).get();
|
return getSettingValue(key);
|
||||||
return row?.value ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSetting(key: string, value: string): void {
|
export function setSetting(key: string, value: string): void {
|
||||||
db.insert(appSettings)
|
upsertSetting(key, value);
|
||||||
.values({ key, value })
|
|
||||||
.onConflictDoUpdate({ target: appSettings.key, set: { value } })
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserCount(): number {
|
export function getUserCount(): number {
|
||||||
const result = db.select({ count: count() }).from(user).get();
|
return queryGetUserCount();
|
||||||
return result?.count ?? 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInstanceId(): string {
|
export function getInstanceId(): string {
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import { access, constants, readdir } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { CACHE_DIR, DATA_DIR, DATABASE_URL, TMDB_API_BASE_URL } from "@sofa/config";
|
import { CACHE_DIR, DATA_DIR, DATABASE_URL, TMDB_API_BASE_URL } from "@sofa/config";
|
||||||
import { db } from "@sofa/db/client";
|
import { getLatestCronRun, getTableCounts } from "@sofa/db/queries/system-health";
|
||||||
import { count, desc, eq } from "@sofa/db/helpers";
|
import type { cronRuns } from "@sofa/db/schema";
|
||||||
import { cronRuns, episodes, titles, user } from "@sofa/db/schema";
|
|
||||||
|
|
||||||
import { listBackups } from "./backup";
|
import { listBackups } from "./backup";
|
||||||
import { imageCacheEnabled } from "./image-cache";
|
import { imageCacheEnabled } from "./image-cache";
|
||||||
@@ -77,16 +76,12 @@ function getDatabaseHealth(): SystemHealthData["database"] {
|
|||||||
walSizeBytes = Bun.file(`${DATABASE_URL}-wal`).size;
|
walSizeBytes = Bun.file(`${DATABASE_URL}-wal`).size;
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const [titleCount] = db.select({ count: count() }).from(titles).all();
|
const counts = getTableCounts();
|
||||||
const [episodeCount] = db.select({ count: count() }).from(episodes).all();
|
|
||||||
const [userCount] = db.select({ count: count() }).from(user).all();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dbSizeBytes,
|
dbSizeBytes,
|
||||||
walSizeBytes,
|
walSizeBytes,
|
||||||
titleCount: titleCount.count,
|
...counts,
|
||||||
episodeCount: episodeCount.count,
|
|
||||||
userCount: userCount.count,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,13 +156,7 @@ function getJobsHealth(): SystemHealthData["jobs"] {
|
|||||||
// Fetch only the latest cron run per job (index-optimized LIMIT 1 each)
|
// Fetch only the latest cron run per job (index-optimized LIMIT 1 each)
|
||||||
const latestByJob = new Map<string, typeof cronRuns.$inferSelect>();
|
const latestByJob = new Map<string, typeof cronRuns.$inferSelect>();
|
||||||
for (const jobName of JOB_NAMES) {
|
for (const jobName of JOB_NAMES) {
|
||||||
const latest = db
|
const latest = getLatestCronRun(jobName);
|
||||||
.select()
|
|
||||||
.from(cronRuns)
|
|
||||||
.where(eq(cronRuns.jobName, jobName))
|
|
||||||
.orderBy(desc(cronRuns.startedAt))
|
|
||||||
.limit(1)
|
|
||||||
.get();
|
|
||||||
if (latest) latestByJob.set(jobName, latest);
|
if (latest) latestByJob.set(jobName, latest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { db } from "@sofa/db/client";
|
import { getTitleCount } from "@sofa/db/queries/title";
|
||||||
import { count } from "@sofa/db/helpers";
|
|
||||||
import { titles } from "@sofa/db/schema";
|
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
|
|
||||||
import { imageCacheEnabled } from "./image-cache";
|
import { imageCacheEnabled } from "./image-cache";
|
||||||
@@ -32,11 +30,6 @@ function bucketTitles(n: number): string {
|
|||||||
return "501+";
|
return "501+";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTitleCount(): number {
|
|
||||||
const result = db.select({ count: count() }).from(titles).get();
|
|
||||||
return result?.count ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function performTelemetryReport(): Promise<void> {
|
export async function performTelemetryReport(): Promise<void> {
|
||||||
if (!isTelemetryEnabled()) {
|
if (!isTelemetryEnabled()) {
|
||||||
log.debug("Telemetry disabled, skipping");
|
log.debug("Telemetry disabled, skipping");
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import path from "node:path";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { rgbaToThumbHash } from "thumbhash";
|
import { rgbaToThumbHash } from "thumbhash";
|
||||||
|
|
||||||
import { db } from "@sofa/db/client";
|
import {
|
||||||
import { eq } from "@sofa/db/helpers";
|
updateEpisodeStillThumbHash,
|
||||||
import { episodes, persons, seasons, titles } from "@sofa/db/schema";
|
updatePersonProfileThumbHash,
|
||||||
|
updateSeasonPosterThumbHash,
|
||||||
|
updateTitleBackdropThumbHash,
|
||||||
|
updateTitlePosterThumbHash,
|
||||||
|
} from "@sofa/db/queries/thumbhash";
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
import type { ImageCategory } from "@sofa/tmdb/image";
|
import type { ImageCategory } from "@sofa/tmdb/image";
|
||||||
|
|
||||||
@@ -48,7 +52,7 @@ export async function generateTitlePosterThumbHash(
|
|||||||
sourceBuffer?: Buffer | null,
|
sourceBuffer?: Buffer | null,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const hash = posterPath ? await generateThumbHash(posterPath, "posters", sourceBuffer) : 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;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +61,7 @@ export async function generateTitleBackdropThumbHash(
|
|||||||
backdropPath: string | null,
|
backdropPath: string | null,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const hash = backdropPath ? await generateThumbHash(backdropPath, "backdrops") : 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;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +70,7 @@ export async function generateSeasonThumbHash(
|
|||||||
posterPath: string | null,
|
posterPath: string | null,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const hash = posterPath ? await generateThumbHash(posterPath, "posters") : 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;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +79,7 @@ export async function generateEpisodeThumbHash(
|
|||||||
stillPath: string | null,
|
stillPath: string | null,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const hash = stillPath ? await generateThumbHash(stillPath, "stills") : 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;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +88,6 @@ export async function generatePersonThumbHash(
|
|||||||
profilePath: string | null,
|
profilePath: string | null,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const hash = profilePath ? await generateThumbHash(profilePath, "profiles") : 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;
|
return hash;
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-279
@@ -1,14 +1,26 @@
|
|||||||
import { db } from "@sofa/db/client";
|
import { getTitleById } from "@sofa/db/queries/title";
|
||||||
import { and, eq, inArray, sql } from "@sofa/db/helpers";
|
|
||||||
import {
|
import {
|
||||||
episodes,
|
batchInsertEpisodeWatchesTransaction,
|
||||||
seasons,
|
batchInsertMissingEpisodeWatches,
|
||||||
titles,
|
countDistinctEpisodeWatches,
|
||||||
userEpisodeWatches,
|
deleteEpisodeWatch,
|
||||||
userMovieWatches,
|
deleteEpisodeWatches,
|
||||||
userRatings,
|
deleteRating,
|
||||||
userTitleStatus,
|
deleteTitleStatus,
|
||||||
} from "@sofa/db/schema";
|
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(
|
export function setTitleStatus(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -17,20 +29,11 @@ export function setTitleStatus(
|
|||||||
_source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
_source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
||||||
addedAt?: Date,
|
addedAt?: Date,
|
||||||
) {
|
) {
|
||||||
const now = addedAt ?? new Date();
|
upsertTitleStatus(userId, titleId, status, addedAt);
|
||||||
db.insert(userTitleStatus)
|
|
||||||
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [userTitleStatus.userId, userTitleStatus.titleId],
|
|
||||||
set: { status, updatedAt: now },
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeTitleStatus(userId: string, titleId: string) {
|
export function removeTitleStatus(userId: string, titleId: string) {
|
||||||
db.delete(userTitleStatus)
|
deleteTitleStatus(userId, titleId);
|
||||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logMovieWatch(
|
export function logMovieWatch(
|
||||||
@@ -40,14 +43,10 @@ export function logMovieWatch(
|
|||||||
watchedAt?: Date,
|
watchedAt?: Date,
|
||||||
) {
|
) {
|
||||||
const now = watchedAt ?? new 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
|
// Auto-set status to completed
|
||||||
const existing = db
|
const existing = getTitleStatus(userId, titleId);
|
||||||
.select()
|
|
||||||
.from(userTitleStatus)
|
|
||||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
setTitleStatus(userId, titleId, "completed", source);
|
setTitleStatus(userId, titleId, "completed", source);
|
||||||
@@ -63,24 +62,14 @@ export function logEpisodeWatch(
|
|||||||
watchedAt?: Date,
|
watchedAt?: Date,
|
||||||
) {
|
) {
|
||||||
const now = watchedAt ?? new 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)
|
// Find the title for this episode (single JOIN instead of 2 queries)
|
||||||
const row = db
|
const titleId = getEpisodeTitleId(episodeId);
|
||||||
.select({ titleId: seasons.titleId })
|
if (!titleId) return;
|
||||||
.from(episodes)
|
|
||||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
|
||||||
.where(eq(episodes.id, episodeId))
|
|
||||||
.get();
|
|
||||||
if (!row) return;
|
|
||||||
const { titleId } = row;
|
|
||||||
|
|
||||||
// Auto-set status to in_progress if not set
|
// Auto-set status to in_progress if not set
|
||||||
const existing = db
|
const existing = getTitleStatus(userId, titleId);
|
||||||
.select()
|
|
||||||
.from(userTitleStatus)
|
|
||||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!existing || existing.status === "watchlist") {
|
if (!existing || existing.status === "watchlist") {
|
||||||
setTitleStatus(userId, titleId, "in_progress", source);
|
setTitleStatus(userId, titleId, "in_progress", source);
|
||||||
@@ -98,85 +87,7 @@ export function logEpisodeWatchBatch(
|
|||||||
) {
|
) {
|
||||||
if (episodeIds.length === 0) return;
|
if (episodeIds.length === 0) return;
|
||||||
|
|
||||||
db.transaction((tx) => {
|
batchInsertEpisodeWatchesTransaction(userId, episodeIds, source, watchedAt);
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markAllEpisodesWatched(
|
export function markAllEpisodesWatched(
|
||||||
@@ -184,121 +95,58 @@ export function markAllEpisodesWatched(
|
|||||||
titleId: string,
|
titleId: string,
|
||||||
source: "manual" | "import" | "plex" | "jellyfin" | "emby" = "manual",
|
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;
|
if (!title || title.type !== "tv") return;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// Single JOIN instead of seasons → episodes chain (2 queries → 1)
|
const epIds = getAllEpisodeIdsForTitle(titleId);
|
||||||
const allEps = db
|
const existingWatches = getExistingEpisodeWatchIds(userId, epIds);
|
||||||
.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);
|
batchInsertMissingEpisodeWatches(userId, epIds, existingWatches, source, now);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setTitleStatus(userId, titleId, "completed", source);
|
setTitleStatus(userId, titleId, "completed", source);
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkAllEpisodesWatched(userId: string, titleId: string) {
|
function checkAllEpisodesWatched(userId: string, titleId: string) {
|
||||||
// Single JOIN instead of seasons → episodes chain (2 queries → 1)
|
const epIds = getAllEpisodeIdsForTitle(titleId);
|
||||||
const allEps = db
|
|
||||||
.select({ id: episodes.id })
|
|
||||||
.from(episodes)
|
|
||||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
|
||||||
.where(eq(seasons.titleId, titleId))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
const totalEpisodes = allEps.length;
|
const totalEpisodes = epIds.length;
|
||||||
if (totalEpisodes === 0) return;
|
if (totalEpisodes === 0) return;
|
||||||
|
|
||||||
const epIds = allEps.map((ep) => ep.id);
|
const watchCount = countDistinctEpisodeWatches(userId, epIds);
|
||||||
const [watchCount] = db
|
|
||||||
.select({
|
|
||||||
count: sql<number>`count(distinct ${userEpisodeWatches.episodeId})`,
|
|
||||||
})
|
|
||||||
.from(userEpisodeWatches)
|
|
||||||
.where(and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, epIds)))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (watchCount.count >= totalEpisodes) {
|
if (watchCount >= totalEpisodes) {
|
||||||
setTitleStatus(userId, titleId, "completed");
|
setTitleStatus(userId, titleId, "completed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unwatchEpisode(userId: string, episodeId: string) {
|
export function unwatchEpisode(userId: string, episodeId: string) {
|
||||||
db.delete(userEpisodeWatches)
|
deleteEpisodeWatch(userId, episodeId);
|
||||||
.where(and(eq(userEpisodeWatches.userId, userId), eq(userEpisodeWatches.episodeId, episodeId)))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
// Find parent title and downgrade from completed to in_progress
|
// Find parent title and downgrade from completed to in_progress
|
||||||
const row = db
|
const titleId = getEpisodeTitleId(episodeId);
|
||||||
.select({ titleId: seasons.titleId })
|
if (!titleId) return;
|
||||||
.from(episodes)
|
|
||||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
|
||||||
.where(eq(episodes.id, episodeId))
|
|
||||||
.get();
|
|
||||||
if (!row) return;
|
|
||||||
|
|
||||||
const existing = db
|
const existing = getTitleStatus(userId, titleId);
|
||||||
.select()
|
|
||||||
.from(userTitleStatus)
|
|
||||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, row.titleId)))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (existing?.status === "completed") {
|
if (existing?.status === "completed") {
|
||||||
setTitleStatus(userId, row.titleId, "in_progress");
|
setTitleStatus(userId, titleId, "in_progress");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unwatchSeason(userId: string, seasonId: string) {
|
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);
|
const epIds = seasonEps.map((ep) => ep.id);
|
||||||
if (epIds.length > 0) {
|
if (epIds.length > 0) {
|
||||||
db.delete(userEpisodeWatches)
|
deleteEpisodeWatches(userId, epIds);
|
||||||
.where(
|
|
||||||
and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, epIds)),
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find parent title and downgrade from completed to in_progress
|
// 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;
|
if (!season) return;
|
||||||
|
|
||||||
const existing = db
|
const existing = getTitleStatus(userId, season.titleId);
|
||||||
.select()
|
|
||||||
.from(userTitleStatus)
|
|
||||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, season.titleId)))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (existing?.status === "completed") {
|
if (existing?.status === "completed") {
|
||||||
setTitleStatus(userId, season.titleId, "in_progress");
|
setTitleStatus(userId, season.titleId, "in_progress");
|
||||||
@@ -313,18 +161,10 @@ export function rateTitleStars(
|
|||||||
) {
|
) {
|
||||||
const now = ratedAt ?? new Date();
|
const now = ratedAt ?? new Date();
|
||||||
if (ratingStars === 0) {
|
if (ratingStars === 0) {
|
||||||
db.delete(userRatings)
|
deleteRating(userId, titleId);
|
||||||
.where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)))
|
|
||||||
.run();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
db.insert(userRatings)
|
upsertRating(userId, titleId, ratingStars, now);
|
||||||
.values({ userId, titleId, ratingStars, ratedAt: now })
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [userRatings.userId, userRatings.titleId],
|
|
||||||
set: { ratingStars, ratedAt: now },
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserStatusesByTitleIds(
|
export function getUserStatusesByTitleIds(
|
||||||
@@ -333,14 +173,7 @@ export function getUserStatusesByTitleIds(
|
|||||||
): Record<string, "watchlist" | "in_progress" | "completed"> {
|
): Record<string, "watchlist" | "in_progress" | "completed"> {
|
||||||
if (titleIds.length === 0) return {};
|
if (titleIds.length === 0) return {};
|
||||||
|
|
||||||
const rows = db
|
const rows = getUserStatusesByTitleIdsQuery(userId, titleIds);
|
||||||
.select({
|
|
||||||
titleId: userTitleStatus.titleId,
|
|
||||||
status: userTitleStatus.status,
|
|
||||||
})
|
|
||||||
.from(userTitleStatus)
|
|
||||||
.where(and(eq(userTitleStatus.userId, userId), inArray(userTitleStatus.titleId, titleIds)))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
const result: Record<string, "watchlist" | "in_progress" | "completed"> = {};
|
const result: Record<string, "watchlist" | "in_progress" | "completed"> = {};
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -355,25 +188,7 @@ export function getEpisodeProgressByTitleIds(
|
|||||||
): Record<string, { watched: number; total: number }> {
|
): Record<string, { watched: number; total: number }> {
|
||||||
if (titleIds.length === 0) return {};
|
if (titleIds.length === 0) return {};
|
||||||
|
|
||||||
const rows = db
|
const rows = getEpisodeProgressByTitleIdsQuery(userId, titleIds);
|
||||||
.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 result: Record<string, { watched: number; total: number }> = {};
|
const result: Record<string, { watched: number; total: number }> = {};
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -388,51 +203,29 @@ export function getEpisodeProgressByTitleIds(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getUserTitleInfo(userId: string, titleId: string) {
|
export function getUserTitleInfo(userId: string, titleId: string) {
|
||||||
const status = db
|
return getUserTitleInfoQuery(userId, titleId);
|
||||||
.select()
|
}
|
||||||
.from(userTitleStatus)
|
|
||||||
.where(and(eq(userTitleStatus.userId, userId), eq(userTitleStatus.titleId, titleId)))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
const rating = db
|
export function quickAddTitle(
|
||||||
.select()
|
userId: string,
|
||||||
.from(userRatings)
|
titleId: string,
|
||||||
.where(and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)))
|
): { id: string; tmdbId: number; type: string; alreadyAdded: boolean } | null {
|
||||||
.get();
|
const title = getTitleById(titleId);
|
||||||
|
if (!title) return null;
|
||||||
|
|
||||||
// Single JOIN instead of seasons → episodes chain (2 queries → 1)
|
const existing = getTitleStatus(userId, titleId);
|
||||||
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);
|
if (!existing) {
|
||||||
|
setTitleStatus(userId, titleId, "watchlist");
|
||||||
|
}
|
||||||
|
|
||||||
// Batch fetch all watches for these episodes
|
return { id: title.id, tmdbId: title.tmdbId, type: title.type, alreadyAdded: !!existing };
|
||||||
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 {
|
export function watchSeason(userId: string, seasonId: string): void {
|
||||||
status: status?.status ?? null,
|
const seasonEps = getSeasonEpisodes(seasonId);
|
||||||
rating: rating?.ratingStars ?? null,
|
logEpisodeWatchBatch(
|
||||||
episodeWatches: watchedEpisodeIds,
|
userId,
|
||||||
};
|
seasonEps.map((ep) => ep.id),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { db } from "@sofa/db/client";
|
import { findEpisodeBySeasonAndNumber, findSeasonByTitleAndNumber } from "@sofa/db/queries/title";
|
||||||
import { and, eq, gte } from "@sofa/db/helpers";
|
|
||||||
import {
|
import {
|
||||||
episodes,
|
getRecentEpisodeWatch,
|
||||||
integrationEvents,
|
getRecentMovieWatch,
|
||||||
integrations,
|
insertIntegrationEvent,
|
||||||
seasons,
|
updateIntegrationLastEvent,
|
||||||
userEpisodeWatches,
|
} from "@sofa/db/queries/webhooks";
|
||||||
userMovieWatches,
|
|
||||||
} from "@sofa/db/schema";
|
|
||||||
import { createLogger } from "@sofa/logger";
|
import { createLogger } from "@sofa/logger";
|
||||||
|
|
||||||
import { resolveMovieTmdbId, resolveShowTmdbId } from "./imports/resolve";
|
import { resolveMovieTmdbId, resolveShowTmdbId } from "./imports/resolve";
|
||||||
@@ -173,33 +170,13 @@ async function resolveEpisode(event: WebhookEvent): Promise<{
|
|||||||
|
|
||||||
function isDuplicateMovieWatch(userId: string, titleId: string): boolean {
|
function isDuplicateMovieWatch(userId: string, titleId: string): boolean {
|
||||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
||||||
const recent = db
|
const recent = getRecentMovieWatch(userId, titleId, fiveMinutesAgo);
|
||||||
.select()
|
|
||||||
.from(userMovieWatches)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userMovieWatches.userId, userId),
|
|
||||||
eq(userMovieWatches.titleId, titleId),
|
|
||||||
gte(userMovieWatches.watchedAt, fiveMinutesAgo),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
return !!recent;
|
return !!recent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDuplicateEpisodeWatch(userId: string, episodeId: string): boolean {
|
function isDuplicateEpisodeWatch(userId: string, episodeId: string): boolean {
|
||||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
||||||
const recent = db
|
const recent = getRecentEpisodeWatch(userId, episodeId, fiveMinutesAgo);
|
||||||
.select()
|
|
||||||
.from(userEpisodeWatches)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userEpisodeWatches.userId, userId),
|
|
||||||
eq(userEpisodeWatches.episodeId, episodeId),
|
|
||||||
gte(userEpisodeWatches.watchedAt, fiveMinutesAgo),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
return !!recent;
|
return !!recent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,27 +188,22 @@ function logEvent(
|
|||||||
status: "success" | "ignored" | "error",
|
status: "success" | "ignored" | "error",
|
||||||
errorMessage?: string,
|
errorMessage?: string,
|
||||||
) {
|
) {
|
||||||
db.insert(integrationEvents)
|
insertIntegrationEvent({
|
||||||
.values({
|
integrationId: connectionId,
|
||||||
integrationId: connectionId,
|
eventType:
|
||||||
eventType:
|
event?.provider === "plex"
|
||||||
event?.provider === "plex"
|
? "media.scrobble"
|
||||||
? "media.scrobble"
|
: event?.provider === "emby"
|
||||||
: event?.provider === "emby"
|
? "playback.stop"
|
||||||
? "playback.stop"
|
: "PlaybackStop",
|
||||||
: "PlaybackStop",
|
mediaType: event?.mediaType ?? null,
|
||||||
mediaType: event?.mediaType ?? null,
|
mediaTitle: event?.title ?? null,
|
||||||
mediaTitle: event?.title ?? null,
|
status,
|
||||||
status,
|
errorMessage: errorMessage ?? null,
|
||||||
errorMessage: errorMessage ?? null,
|
receivedAt: new Date(),
|
||||||
receivedAt: new Date(),
|
});
|
||||||
})
|
|
||||||
.run();
|
|
||||||
|
|
||||||
db.update(integrations)
|
updateIntegrationLastEvent(connectionId);
|
||||||
.set({ lastEventAt: new Date() })
|
|
||||||
.where(eq(integrations.id, connectionId))
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main Processing ────────────────────────────────────────────────
|
// ─── Main Processing ────────────────────────────────────────────────
|
||||||
@@ -293,11 +265,7 @@ export async function processWebhook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find the episode in our DB
|
// Find the episode in our DB
|
||||||
const season = db
|
const season = findSeasonByTitleAndNumber(title.id, resolved.seasonNumber);
|
||||||
.select()
|
|
||||||
.from(seasons)
|
|
||||||
.where(and(eq(seasons.titleId, title.id), eq(seasons.seasonNumber, resolved.seasonNumber)))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!season) {
|
if (!season) {
|
||||||
logEvent(connectionId, event, "error", `Season ${resolved.seasonNumber} not found`);
|
logEvent(connectionId, event, "error", `Season ${resolved.seasonNumber} not found`);
|
||||||
@@ -307,13 +275,7 @@ export async function processWebhook(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const episode = db
|
const episode = findEpisodeBySeasonAndNumber(season.id, resolved.episodeNumber);
|
||||||
.select()
|
|
||||||
.from(episodes)
|
|
||||||
.where(
|
|
||||||
and(eq(episodes.seasonId, season.id), eq(episodes.episodeNumber, resolved.episodeNumber)),
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!episode) {
|
if (!episode) {
|
||||||
logEvent(
|
logEvent(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, spyOn, test } from "bun:test";
|
import { beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||||
|
|
||||||
import { eq } from "@sofa/db/helpers";
|
|
||||||
import {
|
import {
|
||||||
importJobs,
|
importJobs,
|
||||||
titles,
|
titles,
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
} from "@sofa/db/schema";
|
} from "@sofa/db/schema";
|
||||||
import {
|
import {
|
||||||
clearAllTables,
|
clearAllTables,
|
||||||
|
eq,
|
||||||
insertMovieWatch,
|
insertMovieWatch,
|
||||||
insertTvShow,
|
insertTvShow,
|
||||||
insertUser,
|
insertUser,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||||
|
|
||||||
import { eq } from "@sofa/db/helpers";
|
|
||||||
import { episodes, seasons, titles } from "@sofa/db/schema";
|
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 {
|
interface MockSeasonEpisode {
|
||||||
episode_number: number;
|
episode_number: number;
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
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 { 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(
|
const TINY_PNG = Buffer.from(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4//8/AwAI/AL+X2NDNwAAAABJRU5ErkJggg==",
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4//8/AwAI/AL+X2NDNwAAAABJRU5ErkJggg==",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
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 { 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(
|
const TINY_PNG = Buffer.from(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4//8/AwAI/AL+X2NDNwAAAABJRU5ErkJggg==",
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4//8/AwAI/AL+X2NDNwAAAABJRU5ErkJggg==",
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { beforeEach, describe, expect, test } from "bun:test";
|
import { beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
import { and, eq } from "@sofa/db/helpers";
|
|
||||||
import {
|
import {
|
||||||
userEpisodeWatches,
|
userEpisodeWatches,
|
||||||
userMovieWatches,
|
userMovieWatches,
|
||||||
userRatings,
|
userRatings,
|
||||||
userTitleStatus,
|
userTitleStatus,
|
||||||
} from "@sofa/db/schema";
|
} 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 {
|
import {
|
||||||
getUserTitleInfo,
|
getUserTitleInfo,
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
"./client": "./src/client.ts",
|
"./client": "./src/client.ts",
|
||||||
"./schema": "./src/schema.ts",
|
|
||||||
"./helpers": "./src/helpers.ts",
|
|
||||||
"./migrate": "./src/migrate.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": {
|
"scripts": {
|
||||||
"lint": "oxlint",
|
"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();
|
.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