mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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:
-674
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user