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
-674
View File
@@ -1,674 +0,0 @@
/**
* Seed script — populates a Sofa instance with realistic demo data.
*
* Usage:
* bun run db:seed # uses defaults
* bun run db:seed --email demo@sofa.watch --password demo1234 --name "Demo User"
*
* Requires:
* - TMDB_API_READ_ACCESS_TOKEN in .env (titles are fetched live from TMDB)
* - BETTER_AUTH_SECRET in .env
*
* What it creates:
* - A demo user account with email/password login
* - ~20 movies + ~6 TV shows imported from TMDB (with full metadata)
* - Realistic watch history spread across the last 6 months
* - Mixed statuses: completed, in-progress, watchlist
* - Star ratings on completed titles
* - Availability, credits, recommendations, colors enriched via TMDB
* - Webhook connections with sample event log entries
* - Cron run history entries
* - App settings configured
*/
import { parseArgs } from "node:util";
import { eq } from "drizzle-orm";
import { auth } from "@/lib/auth/server";
import { db } from "@/lib/db/client";
import { runMigrations } from "@/lib/db/migrate";
import {
appSettings,
cronRuns,
episodes,
integrationEvents,
integrations,
seasons,
user,
userEpisodeWatches,
userMovieWatches,
userRatings,
userTitleStatus,
} from "@/lib/db/schema";
import { createLogger } from "@/lib/logger";
import { getOrFetchTitleByTmdbId } from "@/lib/services/metadata";
import { setSetting } from "@/lib/services/settings";
const log = createLogger("seed");
// ─── CLI args ───────────────────────────────────────────────────────────────
const { values: args } = parseArgs({
options: {
email: { type: "string", default: "demo@sofa.watch" },
password: { type: "string", default: "password" },
name: { type: "string", default: "Demo User" },
},
strict: false,
});
const DEMO_EMAIL = args.email as string;
const DEMO_PASSWORD = args.password as string;
const DEMO_NAME = args.name as string;
// ─── Title catalog ──────────────────────────────────────────────────────────
// Movies — a curated mix of genres, decades, and popularity
const MOVIES: { tmdbId: number; name: string }[] = [
{ tmdbId: 278, name: "The Shawshank Redemption" },
{ tmdbId: 238, name: "The Godfather" },
{ tmdbId: 155, name: "The Dark Knight" },
{ tmdbId: 550, name: "Fight Club" },
{ tmdbId: 680, name: "Pulp Fiction" },
{ tmdbId: 13, name: "Forrest Gump" },
{ tmdbId: 120, name: "The Lord of the Rings: The Fellowship of the Ring" },
{ tmdbId: 603, name: "The Matrix" },
{ tmdbId: 157336, name: "Interstellar" },
{ tmdbId: 569094, name: "Spider-Man: Across the Spider-Verse" },
{ tmdbId: 27205, name: "Inception" },
{ tmdbId: 244786, name: "Whiplash" },
{ tmdbId: 872585, name: "Oppenheimer" },
{ tmdbId: 346698, name: "Barbie" },
{ tmdbId: 438631, name: "Dune" },
{ tmdbId: 496243, name: "Parasite" },
{ tmdbId: 497, name: "The Green Mile" },
{ tmdbId: 11, name: "Star Wars" },
{ tmdbId: 489, name: "Good Will Hunting" },
{ tmdbId: 1184918, name: "The Wild Robot" },
];
// TV Shows — mix of completed series & currently airing
const TV_SHOWS: { tmdbId: number; name: string }[] = [
{ tmdbId: 1396, name: "Breaking Bad" },
{ tmdbId: 1399, name: "Game of Thrones" },
{ tmdbId: 66732, name: "Stranger Things" },
{ tmdbId: 94997, name: "House of the Dragon" },
{ tmdbId: 60574, name: "Peaky Blinders" },
{ tmdbId: 100088, name: "The Last of Us" },
];
// ─── Helpers ────────────────────────────────────────────────────────────────
function daysAgo(days: number): Date {
const d = new Date();
d.setDate(d.getDate() - days);
return d;
}
function randomBetween(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ─── Main ───────────────────────────────────────────────────────────────────
async function seed() {
log.info("Starting seed...");
// 1. Run migrations to ensure schema is ready
runMigrations();
// 2. Create demo user via Better Auth API
log.info(`Creating demo user: ${DEMO_EMAIL}`);
const existingUser = db
.select()
.from(user)
.where(eq(user.email, DEMO_EMAIL))
.get();
if (existingUser) {
log.warn(
`User ${DEMO_EMAIL} already exists (${existingUser.id}). Using existing user.`,
);
await seedForUser(existingUser.id);
return;
}
// Ensure registration is open so signUpEmail succeeds
await setSetting("registrationOpen", "true");
const result = await auth.api.signUpEmail({
body: {
name: DEMO_NAME,
email: DEMO_EMAIL,
password: DEMO_PASSWORD,
},
});
if (!result?.user) {
throw new Error("Failed to create user via Better Auth signUpEmail");
}
const userId = result.user.id;
log.info(`User created: ${userId} (role: ${result.user.role})`);
await seedForUser(userId);
}
async function seedForUser(userId: string) {
// 3. Import all titles from TMDB
log.info("Importing movies from TMDB...");
const movieTitles: { id: string; tmdbId: number; name: string }[] = [];
for (const movie of MOVIES) {
try {
log.info(` Importing movie: ${movie.name} (TMDB ${movie.tmdbId})`);
const title = await getOrFetchTitleByTmdbId(movie.tmdbId, "movie");
if (title) {
movieTitles.push({
id: title.id,
tmdbId: movie.tmdbId,
name: movie.name,
});
}
// Small delay to respect TMDB rate limits
await delay(300);
} catch (err) {
log.error(` Failed to import ${movie.name}:`, err);
}
}
log.info("Importing TV shows from TMDB...");
const tvTitles: { id: string; tmdbId: number; name: string }[] = [];
for (const show of TV_SHOWS) {
try {
log.info(` Importing TV show: ${show.name} (TMDB ${show.tmdbId})`);
const title = await getOrFetchTitleByTmdbId(show.tmdbId, "tv");
if (title) {
tvTitles.push({ id: title.id, tmdbId: show.tmdbId, name: show.name });
}
await delay(300);
} catch (err) {
log.error(` Failed to import ${show.name}:`, err);
}
}
// Wait for all background enrichment tasks to settle
log.info("Waiting for enrichment tasks to complete...");
await delay(10_000);
// 4. Create watch history and statuses
log.info("Creating watch history...");
// -- Completed movies (watched, rated) --
const completedMovies = movieTitles.slice(0, 12);
for (let i = 0; i < completedMovies.length; i++) {
const movie = completedMovies[i];
const watchedAt = daysAgo(randomBetween(7, 170));
db.insert(userMovieWatches)
.values({
userId,
titleId: movie.id,
watchedAt,
source: i < 10 ? "manual" : "plex",
})
.run();
db.insert(userTitleStatus)
.values({
userId,
titleId: movie.id,
status: "completed",
addedAt: new Date(watchedAt.getTime() - 86400000), // added day before watch
updatedAt: watchedAt,
})
.onConflictDoNothing()
.run();
// Rate most completed movies (mix of 3-5 stars)
const rating = i < 4 ? 5 : i < 8 ? 4 : 3;
db.insert(userRatings)
.values({
userId,
titleId: movie.id,
ratingStars: rating,
ratedAt: watchedAt,
})
.onConflictDoNothing()
.run();
log.info(` Completed + rated: ${movie.name} (${rating} stars)`);
}
// Some movies watched multiple times (rewatches)
for (const movie of completedMovies.slice(0, 3)) {
db.insert(userMovieWatches)
.values({
userId,
titleId: movie.id,
watchedAt: daysAgo(randomBetween(1, 30)),
source: "manual",
})
.run();
log.info(` Rewatch: ${movie.name}`);
}
// -- Watchlist movies (added but not watched) --
const watchlistMovies = movieTitles.slice(12, 17);
for (const movie of watchlistMovies) {
db.insert(userTitleStatus)
.values({
userId,
titleId: movie.id,
status: "watchlist",
addedAt: daysAgo(randomBetween(1, 60)),
updatedAt: daysAgo(randomBetween(0, 5)),
})
.onConflictDoNothing()
.run();
log.info(` Watchlisted: ${movie.name}`);
}
// -- Remaining movies: not tracked (visible in search/explore only) --
for (const movie of movieTitles.slice(17)) {
log.info(` Untracked (discover-only): ${movie.name}`);
}
// 5. TV show tracking
log.info("Creating TV watch history...");
// Breaking Bad — fully completed
const breakingBad = tvTitles.find((t) => t.tmdbId === 1396);
if (breakingBad) {
const allEps = getEpisodesForTitle(breakingBad.id);
const watchedAt = daysAgo(90);
for (const ep of allEps) {
db.insert(userEpisodeWatches)
.values({ userId, episodeId: ep.id, watchedAt, source: "manual" })
.onConflictDoNothing()
.run();
}
db.insert(userTitleStatus)
.values({
userId,
titleId: breakingBad.id,
status: "completed",
addedAt: daysAgo(180),
updatedAt: watchedAt,
})
.onConflictDoNothing()
.run();
db.insert(userRatings)
.values({
userId,
titleId: breakingBad.id,
ratingStars: 5,
ratedAt: watchedAt,
})
.onConflictDoNothing()
.run();
log.info(` Completed: Breaking Bad (${allEps.length} episodes, 5 stars)`);
}
// Game of Thrones — fully completed with lower rating
const got = tvTitles.find((t) => t.tmdbId === 1399);
if (got) {
const allEps = getEpisodesForTitle(got.id);
const watchedAt = daysAgo(120);
for (const ep of allEps) {
db.insert(userEpisodeWatches)
.values({ userId, episodeId: ep.id, watchedAt, source: "manual" })
.onConflictDoNothing()
.run();
}
db.insert(userTitleStatus)
.values({
userId,
titleId: got.id,
status: "completed",
addedAt: daysAgo(180),
updatedAt: watchedAt,
})
.onConflictDoNothing()
.run();
db.insert(userRatings)
.values({
userId,
titleId: got.id,
ratingStars: 3,
ratedAt: watchedAt,
})
.onConflictDoNothing()
.run();
log.info(
` Completed: Game of Thrones (${allEps.length} episodes, 3 stars)`,
);
}
// Stranger Things — in progress (watched first 2 seasons)
const strangerThings = tvTitles.find((t) => t.tmdbId === 66732);
if (strangerThings) {
const seasonRows = db
.select()
.from(seasons)
.where(eq(seasons.titleId, strangerThings.id))
.orderBy(seasons.seasonNumber)
.all();
let watchedCount = 0;
for (const season of seasonRows.slice(0, 2)) {
const eps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, season.id))
.all();
for (const ep of eps) {
db.insert(userEpisodeWatches)
.values({
userId,
episodeId: ep.id,
watchedAt: daysAgo(randomBetween(14, 45)),
source: "manual",
})
.onConflictDoNothing()
.run();
watchedCount++;
}
}
db.insert(userTitleStatus)
.values({
userId,
titleId: strangerThings.id,
status: "in_progress",
addedAt: daysAgo(60),
updatedAt: daysAgo(14),
})
.onConflictDoNothing()
.run();
log.info(
` In progress: Stranger Things (${watchedCount} episodes watched)`,
);
}
// House of the Dragon — in progress (watched season 1 only)
const hotd = tvTitles.find((t) => t.tmdbId === 94997);
if (hotd) {
const seasonRows = db
.select()
.from(seasons)
.where(eq(seasons.titleId, hotd.id))
.orderBy(seasons.seasonNumber)
.all();
let watchedCount = 0;
if (seasonRows.length > 0) {
const eps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, seasonRows[0].id))
.all();
for (const ep of eps) {
db.insert(userEpisodeWatches)
.values({
userId,
episodeId: ep.id,
watchedAt: daysAgo(randomBetween(30, 60)),
source: "jellyfin",
})
.onConflictDoNothing()
.run();
watchedCount++;
}
}
db.insert(userTitleStatus)
.values({
userId,
titleId: hotd.id,
status: "in_progress",
addedAt: daysAgo(75),
updatedAt: daysAgo(30),
})
.onConflictDoNothing()
.run();
log.info(
` In progress: House of the Dragon (${watchedCount} episodes watched)`,
);
}
// Peaky Blinders — on watchlist
const peaky = tvTitles.find((t) => t.tmdbId === 60574);
if (peaky) {
db.insert(userTitleStatus)
.values({
userId,
titleId: peaky.id,
status: "watchlist",
addedAt: daysAgo(20),
updatedAt: daysAgo(20),
})
.onConflictDoNothing()
.run();
log.info(" Watchlisted: Peaky Blinders");
}
// The Last of Us — in progress (watched first 3 episodes)
const tlou = tvTitles.find((t) => t.tmdbId === 100088);
if (tlou) {
const seasonRows = db
.select()
.from(seasons)
.where(eq(seasons.titleId, tlou.id))
.orderBy(seasons.seasonNumber)
.all();
let watchedCount = 0;
if (seasonRows.length > 0) {
const eps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, seasonRows[0].id))
.orderBy(episodes.episodeNumber)
.all();
for (const ep of eps.slice(0, 3)) {
db.insert(userEpisodeWatches)
.values({
userId,
episodeId: ep.id,
watchedAt: daysAgo(randomBetween(2, 10)),
source: "manual",
})
.onConflictDoNothing()
.run();
watchedCount++;
}
}
db.insert(userTitleStatus)
.values({
userId,
titleId: tlou.id,
status: "in_progress",
addedAt: daysAgo(15),
updatedAt: daysAgo(2),
})
.onConflictDoNothing()
.run();
log.info(
` In progress: The Last of Us (${watchedCount} episodes watched)`,
);
}
// 6. Integrations (webhook connections)
log.info("Creating integrations...");
const plexWebhookId = Bun.randomUUIDv7();
const jellyfinWebhookId = Bun.randomUUIDv7();
db.insert(integrations)
.values([
{
id: plexWebhookId,
userId,
provider: "plex",
type: "webhook",
token: `plex_${Bun.randomUUIDv7().replace(/-/g, "").slice(0, 32)}`,
enabled: true,
createdAt: daysAgo(90),
lastEventAt: daysAgo(1),
},
{
id: jellyfinWebhookId,
userId,
provider: "jellyfin",
type: "webhook",
token: `jf_${Bun.randomUUIDv7().replace(/-/g, "").slice(0, 32)}`,
enabled: true,
createdAt: daysAgo(45),
lastEventAt: daysAgo(3),
},
])
.onConflictDoNothing()
.run();
// Sample integration event log
const integrationEventEntries = [
{
integrationId: plexWebhookId,
eventType: "media.scrobble",
mediaType: "movie",
mediaTitle: "The Shawshank Redemption",
status: "success" as const,
receivedAt: daysAgo(5),
},
{
integrationId: plexWebhookId,
eventType: "media.scrobble",
mediaType: "movie",
mediaTitle: "Fight Club",
status: "success" as const,
receivedAt: daysAgo(10),
},
{
integrationId: plexWebhookId,
eventType: "media.play",
mediaType: "movie",
mediaTitle: "Unknown Movie",
status: "ignored" as const,
receivedAt: daysAgo(12),
},
{
integrationId: jellyfinWebhookId,
eventType: "PlaybackStop",
mediaType: "episode",
mediaTitle: "House of the Dragon S01E05",
status: "success" as const,
receivedAt: daysAgo(30),
},
{
integrationId: jellyfinWebhookId,
eventType: "PlaybackStop",
mediaType: "episode",
mediaTitle: "House of the Dragon S01E06",
status: "error" as const,
errorMessage: "Could not match title in database",
receivedAt: daysAgo(29),
},
];
for (const event of integrationEventEntries) {
db.insert(integrationEvents).values(event).run();
}
log.info(
` Created 2 integrations + ${integrationEventEntries.length} events`,
);
// 7. Cron run history
log.info("Creating cron run history...");
const cronJobs = [
"nightlyRefreshLibrary",
"refreshAvailability",
"refreshRecommendations",
"refreshTvChildren",
"cacheImages",
"refreshCredits",
"updateCheck",
];
for (const jobName of cronJobs) {
// Create 3-5 historical runs per job
const runCount = randomBetween(3, 5);
for (let i = 0; i < runCount; i++) {
const startedAt = daysAgo(i * 2 + randomBetween(0, 1));
const durationMs = randomBetween(500, 30000);
const finishedAt = new Date(startedAt.getTime() + durationMs);
const isError = Math.random() < 0.05; // 5% error rate
db.insert(cronRuns)
.values({
jobName,
status: isError ? "error" : "success",
startedAt,
finishedAt,
durationMs,
errorMessage: isError ? "TMDB API rate limit exceeded" : null,
})
.run();
}
}
log.info(" Created cron run history");
// 8. App settings
log.info("Configuring app settings...");
const settings: [string, string][] = [
["registrationOpen", "false"],
["backupEnabled", "true"],
["backupSchedule", "0 4 * * *"],
["backupRetentionDays", "30"],
];
for (const [key, value] of settings) {
db.insert(appSettings)
.values({ key, value })
.onConflictDoUpdate({ target: appSettings.key, set: { value } })
.run();
}
log.info(" App settings configured");
// Done!
const movieCount = movieTitles.length;
const tvCount = tvTitles.length;
log.info("─────────────────────────────────────────────────");
log.info("Seed complete!");
log.info(` User: ${DEMO_EMAIL} / ${DEMO_PASSWORD}`);
log.info(` Movies: ${movieCount} imported`);
log.info(` TV Shows: ${tvCount} imported`);
log.info(` Library: completed, in-progress, and watchlist items`);
log.info(` Extras: ratings, webhooks, cron history, app settings`);
log.info("─────────────────────────────────────────────────");
}
function getEpisodesForTitle(titleId: string) {
const seasonRows = db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
const allEps: { id: string }[] = [];
for (const season of seasonRows) {
const eps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, season.id))
.all();
allEps.push(...eps);
}
return allEps;
}
// ─── Run ────────────────────────────────────────────────────────────────────
seed().catch((err) => {
log.error("Seed failed:", err);
process.exit(1);
});