Migrate web app from Next.js to Vite + TanStack Router SPA (#6)

* Convert to Turborepo monorepo with shared API contract package

Restructure the repository as a monorepo in preparation for adding
future clients (mobile app, CLI). Extract the oRPC contract and Zod
schemas into `@sofa/api` (packages/api/) as a JIT internal package,
and relocate the Next.js app to `@sofa/web` (apps/web/).

- Add Turborepo with Bun workspaces for task orchestration and caching
- Extract `contract.ts` and `schemas.ts` into `@sofa/api` package
- Move all app code, configs, tests, and migrations to `apps/web/`
- Update 17 import paths from `@/lib/orpc/schemas` to `@sofa/api/schemas`
- Add `outputFileTracingRoot` and `transpilePackages` to next.config.ts
- Rewrite Dockerfile with `turbo prune --docker` for efficient builds
- Update CI workflows to use `turbo run` for lint/check-types/test
- Update CLAUDE.md with monorepo structure and commands

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Extract standalone Hono API server and split shared packages

Separate all server-side concerns from the Next.js frontend into a new
`apps/server/` Hono app and dedicated shared packages, making `@sofa/web`
a frontend-only app with no direct DB or service access.

- Add `@sofa/server` (`apps/server/`) — Hono API on port 3001 hosting
  oRPC procedures, Better Auth, cron jobs, and non-RPC routes
- Add `@sofa/core` (`packages/core/`) — All 15 business logic services
  moved from `apps/web/lib/services/`; tests moved to `packages/core/test/`
- Add `@sofa/db` (`packages/db/`) — DB client, schema, migrations,
  constants, and logger extracted from `apps/web/lib/db/` and `lib/`
- Add `@sofa/tmdb` (`packages/tmdb/`) — TMDB client and image helpers
  moved from `apps/web/lib/tmdb/`
- Add `@sofa/auth` (`packages/auth/`) — Better Auth server config moved
  from `apps/web/lib/auth/`
- Move oRPC procedures, handler, router, middleware to `apps/server/src/orpc/`
- Move Hono route handlers (avatars, backups, images, lists, webhooks,
  health) to `apps/server/src/routes/`; delete equivalent Next.js API routes
- Strip `apps/web` to frontend-only: no DB imports, no service imports,
  all data via oRPC client calls to the API server
- Add `entrypoint.sh` to start API server, wait for health, then Next.js
- Update `next.config.ts` rewrites to proxy `/rpc/*` and `/api/*` to
  `INTERNAL_API_URL` (default `http://localhost:3001`)
- Update Dockerfile and CLAUDE.md for the new structure

* Migrate web app from Next.js to Vite + TanStack Router SPA and add workspace catalog

Replace Next.js with a pure Vite SPA using TanStack Router for file-based routing,
removing all SSR complexity. The API server (Hono) now serves both API routes and
SPA static files in production, simplifying Docker to a single-process container.

Key changes:
- Vite 7 + @tanstack/react-router with file-based routing via plugin
- Route guards via beforeLoad + authClient.getSession() (replaces server-side auth)
- Route loaders with queryClient.ensureQueryData() (replaces SSR data fetching)
- Self-hosted fonts via @fontsource (replaces next/font/google)
- Tailwind v4 via @tailwindcss/vite (replaces @tailwindcss/postcss)
- Single oRPC client (removed SSR client and server-side session helper)
- Hono serves SPA static files in production (single port 3000)
- Single-process Dockerfile (removed entrypoint.sh)
- Bun workspace catalog for centralized dependency version management

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Extract @sofa/logger and @sofa/config shared packages

- Add `@sofa/logger` (`packages/logger/`) — standalone logger package
  extracted from `@sofa/db/logger`; update all imports across server,
  core, auth, db, and tmdb packages
- Add `@sofa/config` (`packages/config/`) — standalone config/constants
  package extracted from `@sofa/db/constants`; exports `DATA_DIR`,
  `DATABASE_URL`, `CACHE_DIR`, `AVATAR_DIR`, `BACKUP_DIR`
- Move `.env.example` from `apps/web/` to repo root; update server dev
  scripts to load it via `--env-file=../../.env`
- Move image serving from `/api/images` to `/images`; add `serveStatic`
  fast path in `index.ts` for cached files before falling back to the
  TMDB fetch route; add `/images` proxy to Vite dev config
- Fix `Sparkline` component: replace `ResponsiveContainer` with
  `ResizeObserver` to avoid SSR/hydration issues with recharts
- Replace `VITE_SERVER_URL` env var with `window.location.origin` in
  the oRPC client (always same-origin in both dev and production)

* Fix asset caching, SPA 404 fallback, and DATA_DIR resolution

- Add `Cache-Control: immutable` header for hashed `/assets/*` files;
  return 404 for missing asset paths instead of falling back to
  `index.html` (prevents serving stale chunks after deploy)
- Wrap `query.invalidate` in an arrow function in the oRPC QueryClient
  error handler to avoid illegal invocation errors
- Resolve `DATA_DIR` to an absolute path via `path.resolve()` so
  relative paths work regardless of the process working directory

* Migrate @sofa/logger to pino for structured logging

- Replace custom logger implementation in `packages/logger/` with pino
  + pino-pretty; add both as workspace catalog dependencies
- Add `pino` and `pino-pretty` to the workspace catalog in `package.json`
- Fix `log.error()` calls in oRPC and OpenAPI handlers to pass the
  error directly instead of wrapping it in `{ error }` to match pino's
  serializer expectations

* Rename discoverProcedure/statsProcedure exports to discover/stats

* Add TanStackDevtools unified panel and VS Code workspace config

- Replace separate Router/Query devtools with unified `TanStackDevtools`
  from `@tanstack/react-devtools` + `@tanstack/devtools-vite` plugin
- Wrap app in `<StrictMode>` in `main.tsx`
- Add `.vscode/settings.json` (Biome formatter, format-on-save, readonly
  `routeTree.gen.ts`) and `.vscode/extensions.json` (recommended extensions)

* Move test DB helpers to @sofa/db/test-utils and add root bunfig.toml

Extract in-memory SQLite setup and fixture helpers (insertUser, insertTitle,
etc.) from packages/core/test/sqlite.ts into packages/db/src/test-utils.ts
so DB test utilities live alongside the schema they depend on. Use
import.meta.dir for CWD-independent migration path resolution.

Add root bunfig.toml so `bun test` works from the repo root in addition
to `bun run test` (turbo).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix devtools plugin order and whitespace-only TMDB token check

Move devtools() to first position in Vite plugins array per TanStack
docs, and trim TMDB_API_READ_ACCESS_TOKEN before boolean coercion so
whitespace-only values are treated as unconfigured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 16:50:34 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 26558b29e4
commit a326c968b7
325 changed files with 26970 additions and 28519 deletions
+64
View File
@@ -0,0 +1,64 @@
import { Database } from "bun:sqlite";
import { DATABASE_URL } from "@sofa/config";
import { createLogger } from "@sofa/logger";
import type { Logger } from "drizzle-orm";
import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema";
const log = createLogger("drizzle");
const drizzleLogger: Logger = {
logQuery(query: string, params: unknown[]) {
log.debug(query, params.length ? params : "");
},
};
// Lazy-init singleton via globalThis. Next.js evaluates module-level code at
// build time (when no database exists) and re-imports modules on HMR in dev
// (which would create duplicate connections). Stashing the instances on
// globalThis and wrapping `db` in a Proxy defers all real work to the first
// property access at runtime, sidestepping both problems.
//
// Only the Drizzle instance (`db`) is exported as a Proxy — the raw bun:sqlite
// Database is kept internal because its native C++ methods lose their `this`
// binding when accessed through Reflect.get, so a Proxy around it would break.
// Use `closeDatabase()` for graceful shutdown instead.
const globalForDb = globalThis as unknown as {
_db: ReturnType<typeof drizzle> | undefined;
_client: Database | undefined;
};
function getClient() {
if (!globalForDb._client) {
globalForDb._client = new Database(DATABASE_URL);
globalForDb._client.run("PRAGMA journal_mode = WAL");
globalForDb._client.run("PRAGMA foreign_keys = ON");
globalForDb._client.run("PRAGMA busy_timeout = 5000");
}
return globalForDb._client;
}
function getDb() {
if (!globalForDb._db) {
globalForDb._db = drizzle({
client: getClient(),
schema,
logger: drizzleLogger,
});
}
return globalForDb._db;
}
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
get(_, prop) {
return Reflect.get(getDb(), prop);
},
});
/** Close the current connection, and clear singletons so the Proxy re-initializes on next access. */
export function closeDatabase() {
globalForDb._client?.close();
globalForDb._client = undefined;
globalForDb._db = undefined;
}
+12
View File
@@ -0,0 +1,12 @@
export {
and,
count,
desc,
eq,
gte,
inArray,
isNotNull,
lt,
or,
sql,
} from "drizzle-orm";
+14
View File
@@ -0,0 +1,14 @@
import path from "node:path";
import { createLogger } from "@sofa/logger";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { db } from "./client";
const log = createLogger("db");
export function runMigrations(
migrationsFolder = path.join(import.meta.dir, "../drizzle"),
) {
log.info("Running database migrations...");
migrate(db, { migrationsFolder });
log.info("Database migrations complete");
}
+459
View File
@@ -0,0 +1,459 @@
import {
index,
int,
real,
sqliteTable,
text,
uniqueIndex,
} from "drizzle-orm/sqlite-core";
// Helper for UUID primary keys
const uuidPk = () =>
text("id")
.primaryKey()
.$defaultFn(() => Bun.randomUUIDv7());
// ─── Better Auth tables ──────────────────────────────────────────────
export const user = sqliteTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: int("emailVerified", { mode: "boolean" })
.notNull()
.default(false),
image: text("image"),
role: text("role").default("user"),
banned: int("banned", { mode: "boolean" }).default(false),
banReason: text("banReason"),
banExpires: int("banExpires", { mode: "timestamp" }),
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
updatedAt: int("updatedAt", { mode: "timestamp" }).notNull(),
});
export const session = sqliteTable("session", {
id: text("id").primaryKey(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
expiresAt: int("expiresAt", { mode: "timestamp" }).notNull(),
ipAddress: text("ipAddress"),
userAgent: text("userAgent"),
impersonatedBy: text("impersonatedBy"),
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
updatedAt: int("updatedAt", { mode: "timestamp" }).notNull(),
});
export const account = sqliteTable("account", {
id: text("id").primaryKey(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accountId: text("accountId").notNull(),
providerId: text("providerId").notNull(),
accessToken: text("accessToken"),
refreshToken: text("refreshToken"),
idToken: text("idToken"),
accessTokenExpiresAt: int("accessTokenExpiresAt", { mode: "timestamp" }),
refreshTokenExpiresAt: int("refreshTokenExpiresAt", { mode: "timestamp" }),
scope: text("scope"),
password: text("password"),
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
updatedAt: int("updatedAt", { mode: "timestamp" }).notNull(),
});
export const verification = sqliteTable("verification", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: int("expiresAt", { mode: "timestamp" }).notNull(),
createdAt: int("createdAt", { mode: "timestamp" }),
updatedAt: int("updatedAt", { mode: "timestamp" }),
});
// ─── Genres ─────────────────────────────────────────────────────────
export const genres = sqliteTable("genres", {
id: int("id").primaryKey(),
name: text("name").notNull(),
});
export const titleGenres = sqliteTable(
"titleGenres",
{
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
genreId: int("genreId")
.notNull()
.references(() => genres.id, { onDelete: "cascade" }),
},
(table) => [
uniqueIndex("titleGenres_titleId_genreId").on(table.titleId, table.genreId),
index("titleGenres_genreId").on(table.genreId),
],
);
// ─── App tables ──────────────────────────────────────────────────────
export const titles = sqliteTable(
"titles",
{
id: uuidPk(),
tmdbId: int("tmdbId").notNull(),
tvdbId: int("tvdbId"),
type: text("type", { enum: ["movie", "tv"] }).notNull(),
title: text("title").notNull(),
originalTitle: text("originalTitle"),
overview: text("overview"),
releaseDate: text("releaseDate"),
firstAirDate: text("firstAirDate"),
posterPath: text("posterPath"),
backdropPath: text("backdropPath"),
popularity: real("popularity"),
voteAverage: real("voteAverage"),
voteCount: int("voteCount"),
status: text("status"),
contentRating: text("contentRating"),
colorPalette: text("colorPalette"),
trailerVideoKey: text("trailerVideoKey"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("titles_tmdbId_unique").on(table.tmdbId),
index("titles_type_releaseDate").on(table.type, table.releaseDate),
index("titles_type_firstAirDate").on(table.type, table.firstAirDate),
index("titles_lastFetchedAt").on(table.lastFetchedAt),
index("titles_type_status_lastFetchedAt").on(
table.type,
table.status,
table.lastFetchedAt,
),
],
);
export const seasons = sqliteTable(
"seasons",
{
id: uuidPk(),
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
seasonNumber: int("seasonNumber").notNull(),
name: text("name"),
overview: text("overview"),
posterPath: text("posterPath"),
airDate: text("airDate"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("seasons_titleId_seasonNumber").on(
table.titleId,
table.seasonNumber,
),
],
);
export const episodes = sqliteTable(
"episodes",
{
id: uuidPk(),
seasonId: text("seasonId")
.notNull()
.references(() => seasons.id, { onDelete: "cascade" }),
episodeNumber: int("episodeNumber").notNull(),
name: text("name"),
overview: text("overview"),
stillPath: text("stillPath"),
airDate: text("airDate"),
runtimeMinutes: int("runtimeMinutes"),
},
(table) => [
uniqueIndex("episodes_seasonId_episodeNumber").on(
table.seasonId,
table.episodeNumber,
),
],
);
export const userTitleStatus = sqliteTable(
"userTitleStatus",
{
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
status: text("status", {
enum: ["watchlist", "in_progress", "completed"],
}).notNull(),
addedAt: int("addedAt", { mode: "timestamp" }).notNull(),
updatedAt: int("updatedAt", { mode: "timestamp" }).notNull(),
},
(table) => [
uniqueIndex("userTitleStatus_userId_titleId").on(
table.userId,
table.titleId,
),
index("userTitleStatus_userId_status").on(table.userId, table.status),
],
);
export const userMovieWatches = sqliteTable(
"userMovieWatches",
{
id: uuidPk(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(),
source: text("source", {
enum: ["manual", "import", "plex", "jellyfin", "emby"],
})
.notNull()
.default("manual"),
},
(table) => [
index("userMovieWatches_userId_watchedAt").on(
table.userId,
table.watchedAt,
),
index("userMovieWatches_titleId").on(table.titleId),
index("userMovieWatches_userId_titleId").on(table.userId, table.titleId),
],
);
export const userEpisodeWatches = sqliteTable(
"userEpisodeWatches",
{
id: uuidPk(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
episodeId: text("episodeId")
.notNull()
.references(() => episodes.id, { onDelete: "cascade" }),
watchedAt: int("watchedAt", { mode: "timestamp" }).notNull(),
source: text("source", {
enum: ["manual", "import", "plex", "jellyfin", "emby"],
})
.notNull()
.default("manual"),
},
(table) => [
index("userEpisodeWatches_userId_watchedAt").on(
table.userId,
table.watchedAt,
),
index("userEpisodeWatches_episodeId").on(table.episodeId),
index("userEpisodeWatches_userId_episodeId").on(
table.userId,
table.episodeId,
),
],
);
export const userRatings = sqliteTable(
"userRatings",
{
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
ratingStars: int("ratingStars").notNull(),
ratedAt: int("ratedAt", { mode: "timestamp" }).notNull(),
},
(table) => [
uniqueIndex("userRatings_userId_titleId").on(table.userId, table.titleId),
],
);
export const availabilityOffers = sqliteTable(
"availabilityOffers",
{
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
region: text("region").notNull().default("US"),
providerId: int("providerId").notNull(),
providerName: text("providerName").notNull(),
logoPath: text("logoPath"),
offerType: text("offerType", {
enum: ["flatrate", "rent", "buy", "free", "ads"],
}).notNull(),
link: text("link"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("availabilityOffers_unique").on(
table.titleId,
table.region,
table.providerId,
table.offerType,
),
],
);
export const titleRecommendations = sqliteTable(
"titleRecommendations",
{
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
recommendedTitleId: text("recommendedTitleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
source: text("source", {
enum: ["tmdb_similar", "tmdb_recommendations"],
}).notNull(),
rank: int("rank").notNull(),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("titleRecommendations_unique").on(
table.titleId,
table.recommendedTitleId,
table.source,
),
index("titleRecommendations_titleId_rank").on(table.titleId, table.rank),
],
);
// ─── Persons & Cast ─────────────────────────────────────────────────
export const persons = sqliteTable(
"persons",
{
id: uuidPk(),
tmdbId: int("tmdbId").notNull(),
name: text("name").notNull(),
biography: text("biography"),
birthday: text("birthday"),
deathday: text("deathday"),
placeOfBirth: text("placeOfBirth"),
profilePath: text("profilePath"),
knownForDepartment: text("knownForDepartment"),
popularity: real("popularity"),
imdbId: text("imdbId"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("persons_tmdbId_unique").on(table.tmdbId),
index("persons_name").on(table.name),
],
);
export const titleCast = sqliteTable(
"titleCast",
{
id: uuidPk(),
titleId: text("titleId")
.notNull()
.references(() => titles.id, { onDelete: "cascade" }),
personId: text("personId")
.notNull()
.references(() => persons.id, { onDelete: "cascade" }),
character: text("character"),
department: text("department").notNull().default("Acting"),
job: text("job"),
displayOrder: int("displayOrder").notNull().default(0),
episodeCount: int("episodeCount"),
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("titleCast_unique").on(
table.titleId,
table.personId,
table.department,
table.character,
),
index("titleCast_titleId_displayOrder").on(
table.titleId,
table.displayOrder,
),
index("titleCast_personId").on(table.personId),
],
);
// ─── Integrations ───────────────────────────────────────────────────
export const integrations = sqliteTable(
"integrations",
{
id: uuidPk(),
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
provider: text("provider").notNull(),
type: text("type", { enum: ["webhook", "list"] }).notNull(),
token: text("token").notNull().unique(),
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
lastEventAt: int("lastEventAt", { mode: "timestamp" }),
},
(table) => [
uniqueIndex("integrations_userId_provider").on(
table.userId,
table.provider,
),
uniqueIndex("integrations_token").on(table.token),
],
);
export const integrationEvents = sqliteTable(
"integrationEvents",
{
id: uuidPk(),
integrationId: text("integrationId")
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
eventType: text("eventType"),
mediaType: text("mediaType"),
mediaTitle: text("mediaTitle"),
status: text("status", {
enum: ["success", "ignored", "error"],
}).notNull(),
errorMessage: text("errorMessage"),
receivedAt: int("receivedAt", { mode: "timestamp" }).notNull(),
},
(table) => [
index("integrationEvents_integrationId_receivedAt").on(
table.integrationId,
table.receivedAt,
),
],
);
// ─── Cron Run History ────────────────────────────────────────────────
export const cronRuns = sqliteTable(
"cronRuns",
{
id: uuidPk(),
jobName: text("jobName").notNull(),
status: text("status", {
enum: ["running", "success", "error"],
}).notNull(),
startedAt: int("startedAt", { mode: "timestamp" }).notNull(),
finishedAt: int("finishedAt", { mode: "timestamp" }),
durationMs: int("durationMs"),
errorMessage: text("errorMessage"),
},
(table) => [
index("cronRuns_jobName_startedAt").on(table.jobName, table.startedAt),
],
);
// ─── App Settings ───────────────────────────────────────────────────
export const appSettings = sqliteTable("appSettings", {
key: text("key").primaryKey(),
value: text("value"),
});
+225
View File
@@ -0,0 +1,225 @@
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import * as schema from "./schema";
const {
user,
titles,
seasons,
episodes,
userMovieWatches,
userEpisodeWatches,
userTitleStatus,
userRatings,
availabilityOffers,
titleRecommendations,
integrations,
} = schema;
export const testClient = new Database(":memory:");
testClient.run("PRAGMA foreign_keys = ON");
export const testDb = drizzle({ client: testClient, schema });
export function applyMigrations() {
const migrationsFolder = `${import.meta.dir}/../drizzle`;
migrate(testDb, { migrationsFolder });
}
export function clearAllTables() {
const tables = testClient
.query(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '__drizzle%'",
)
.all() as { name: string }[];
testClient.run("PRAGMA foreign_keys = OFF");
for (const { name } of tables) {
testClient.run(`DELETE FROM "${name}"`);
}
testClient.run("PRAGMA foreign_keys = ON");
}
const now = new Date();
export function insertUser(id = "user-1") {
testDb
.insert(user)
.values({
id,
name: "Test User",
email: `${id}@test.com`,
emailVerified: true,
createdAt: now,
updatedAt: now,
})
.run();
return id;
}
export function insertTitle(
overrides: {
id?: string;
tmdbId?: number;
tvdbId?: number;
type?: "movie" | "tv";
title?: string;
} = {},
) {
const id = overrides.id ?? "title-1";
testDb
.insert(titles)
.values({
id,
tmdbId: overrides.tmdbId ?? 12345,
tvdbId: overrides.tvdbId,
type: overrides.type ?? "movie",
title: overrides.title ?? "Test Movie",
})
.run();
return id;
}
export function insertTvShow(
titleId = "tv-1",
tmdbId = 99999,
seasonCount = 1,
epsPerSeason = 3,
) {
insertTitle({ id: titleId, tmdbId, type: "tv", title: "Test Show" });
const episodeIds: string[] = [];
for (let s = 1; s <= seasonCount; s++) {
const seasonId = `${titleId}-s${s}`;
testDb
.insert(seasons)
.values({ id: seasonId, titleId, seasonNumber: s })
.run();
for (let e = 1; e <= epsPerSeason; e++) {
const epId = `${titleId}-s${s}e${e}`;
testDb
.insert(episodes)
.values({
id: epId,
seasonId,
episodeNumber: e,
name: `S${s}E${e}`,
})
.run();
episodeIds.push(epId);
}
}
return { titleId, episodeIds };
}
export function insertMovieWatch(
userId: string,
titleId: string,
watchedAt?: Date,
) {
testDb
.insert(userMovieWatches)
.values({
userId,
titleId,
watchedAt: watchedAt ?? new Date(),
source: "manual",
})
.run();
}
export function insertEpisodeWatch(
userId: string,
episodeId: string,
watchedAt?: Date,
) {
testDb
.insert(userEpisodeWatches)
.values({
userId,
episodeId,
watchedAt: watchedAt ?? new Date(),
source: "manual",
})
.run();
}
export function insertStatus(
userId: string,
titleId: string,
status: "watchlist" | "in_progress" | "completed",
) {
const now = new Date();
testDb
.insert(userTitleStatus)
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
.run();
}
export function insertRating(
userId: string,
titleId: string,
ratingStars: number,
) {
testDb
.insert(userRatings)
.values({ userId, titleId, ratingStars, ratedAt: new Date() })
.run();
}
export function insertAvailabilityOffer(
titleId: string,
overrides: {
providerId?: number;
providerName?: string;
offerType?: "flatrate" | "rent" | "buy" | "free" | "ads";
} = {},
) {
testDb
.insert(availabilityOffers)
.values({
titleId,
providerId: overrides.providerId ?? 8,
providerName: overrides.providerName ?? "Netflix",
offerType: overrides.offerType ?? "flatrate",
})
.run();
}
export function insertIntegration(
userId: string,
provider: string,
token = "test-token",
) {
const type =
provider === "sonarr" || provider === "radarr" ? "list" : "webhook";
return testDb
.insert(integrations)
.values({
userId,
provider,
type,
token,
enabled: true,
createdAt: new Date(),
})
.returning()
.get();
}
export function insertRecommendation(
titleId: string,
recommendedTitleId: string,
overrides: {
source?: "tmdb_recommendations" | "tmdb_similar";
rank?: number;
} = {},
) {
testDb
.insert(titleRecommendations)
.values({
titleId,
recommendedTitleId,
source: overrides.source ?? "tmdb_recommendations",
rank: overrides.rank ?? 1,
})
.run();
}