mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
Add Docker packaging and migrate from better-sqlite3 to libsql
Docker self-hosting support: - Dockerfile (multi-stage Alpine build with tini init) - docker-compose.yml with named volume for SQLite persistence - /api/health endpoint for container health checks - Auto-migration on startup via drizzle-orm/libsql/migrator - Graceful shutdown (SIGTERM stops scheduler, closes DB) - Next.js standalone output mode for minimal image size Database driver migration (better-sqlite3 → @libsql/client): - Eliminates native C++ compilation, enabling Alpine Docker images - All DB queries converted from sync to async across services and routes - DATABASE_URL now uses libsql file: prefix format - drizzle.config.ts dialect changed to turso for libsql support - Initial migration files generated in drizzle/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
*.db
|
||||
*.db-*
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
.vercel
|
||||
coverage
|
||||
.vscode
|
||||
.idea
|
||||
+8
-1
@@ -1,4 +1,11 @@
|
||||
DATABASE_URL=sqlite.db
|
||||
# SQLite database URL (Docker: file:/data/sqlite.db, local dev: file:sqlite.db)
|
||||
DATABASE_URL=file:sqlite.db
|
||||
|
||||
# TMDB API key — get one at https://www.themoviedb.org/settings/api
|
||||
TMDB_API_KEY=your_tmdb_api_key_here
|
||||
|
||||
# Random secret for session encryption (min 32 chars)
|
||||
BETTER_AUTH_SECRET=your_secret_here
|
||||
|
||||
# Public URL of your instance
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
+3
-2
@@ -23,9 +23,10 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# sqlite
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db-*
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
||||
@@ -22,7 +22,7 @@ pnpm db:studio # Open Drizzle Studio (visual DB browser)
|
||||
### Stack
|
||||
|
||||
- **Framework**: Next.js 16 (App Router), React 19, TypeScript
|
||||
- **Database**: SQLite via better-sqlite3 + Drizzle ORM (WAL mode, singleton via `globalThis`)
|
||||
- **Database**: SQLite via @libsql/client + Drizzle ORM (WAL mode, singleton via `globalThis`, async queries)
|
||||
- **Auth**: Better Auth with Drizzle adapter, email/password
|
||||
- **Styling**: Tailwind CSS v4, shadcn components, dark cinema theme with warm primary accents
|
||||
- **Fonts**: DM Serif Display (display), DM Sans (body), Geist Mono (mono)
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
FROM node:24-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# --- Dependencies ---
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# --- Builder ---
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NODE_ENV=production
|
||||
RUN pnpm build
|
||||
|
||||
# --- Runner ---
|
||||
FROM node:24-alpine AS runner
|
||||
RUN apk add --no-cache tini
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs \
|
||||
&& mkdir -p /data && chown nextjs:nodejs /data
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
VOLUME /data
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
CMD ["node", "server.js"]
|
||||
@@ -15,7 +15,7 @@ export async function POST(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
logEpisodeWatch(session.user.id, id);
|
||||
await logEpisodeWatch(session.user.id, id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -30,6 +30,6 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
unwatchEpisode(session.user.id, id);
|
||||
await unwatchEpisode(session.user.id, id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -10,6 +10,6 @@ export async function GET() {
|
||||
if (!session)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const feed = getContinueWatchingFeed(session.user.id);
|
||||
const feed = await getContinueWatchingFeed(session.user.id);
|
||||
return NextResponse.json(feed);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const days = Number(req.nextUrl.searchParams.get("days") ?? 14);
|
||||
const feed = getNewAvailableFeed(session.user.id, days);
|
||||
const feed = await getNewAvailableFeed(session.user.id, days);
|
||||
return NextResponse.json(feed);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,6 @@ export async function GET() {
|
||||
if (!session)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const feed = getRecommendationsFeed(session.user.id);
|
||||
const feed = await getRecommendationsFeed(session.user.id);
|
||||
return NextResponse.json(feed);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function GET() {
|
||||
weekStart.setDate(now.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
|
||||
weekStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const [moviesThisMonth] = db
|
||||
const [moviesThisMonth] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(userMovieWatches)
|
||||
.where(
|
||||
@@ -38,7 +38,7 @@ export async function GET() {
|
||||
)
|
||||
.all();
|
||||
|
||||
const [episodesThisWeek] = db
|
||||
const [episodesThisWeek] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(userEpisodeWatches)
|
||||
.where(
|
||||
@@ -49,13 +49,13 @@ export async function GET() {
|
||||
)
|
||||
.all();
|
||||
|
||||
const [librarySize] = db
|
||||
const [librarySize] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(userTitleStatus)
|
||||
.where(eq(userTitleStatus.userId, userId))
|
||||
.all();
|
||||
|
||||
const [completedCount] = db
|
||||
const [completedCount] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({ status: "ok" });
|
||||
}
|
||||
@@ -15,6 +15,6 @@ export async function POST(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
logMovieWatch(session.user.id, id);
|
||||
await logMovieWatch(session.user.id, id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -18,14 +18,14 @@ export async function POST(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const seasonEps = db
|
||||
const seasonEps = await db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.seasonId, id))
|
||||
.all();
|
||||
|
||||
for (const ep of seasonEps) {
|
||||
logEpisodeWatch(session.user.id, ep.id);
|
||||
await logEpisodeWatch(session.user.id, ep.id);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
@@ -42,6 +42,6 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
unwatchSeason(session.user.id, id);
|
||||
await unwatchSeason(session.user.id, id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -25,6 +25,6 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
rateTitleStars(session.user.id, id, ratingStars);
|
||||
await rateTitleStars(session.user.id, id, ratingStars);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ export async function GET(
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const title = db.select().from(titles).where(eq(titles.id, id)).get();
|
||||
const title = await db.select().from(titles).where(eq(titles.id, id)).get();
|
||||
if (!title)
|
||||
return NextResponse.json({ error: "Title not found" }, { status: 404 });
|
||||
|
||||
const recs = db
|
||||
const recs = await db
|
||||
.select({
|
||||
recommendedTitleId: titleRecommendations.recommendedTitleId,
|
||||
source: titleRecommendations.source,
|
||||
@@ -24,18 +24,20 @@ export async function GET(
|
||||
.orderBy(titleRecommendations.rank)
|
||||
.all();
|
||||
|
||||
const results = recs
|
||||
.map((rec) => {
|
||||
const recTitle = db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.id, rec.recommendedTitleId))
|
||||
.get();
|
||||
return recTitle
|
||||
? { ...recTitle, source: rec.source, rank: rec.rank }
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
const results = (
|
||||
await Promise.all(
|
||||
recs.map(async (rec) => {
|
||||
const recTitle = await db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.id, rec.recommendedTitleId))
|
||||
.get();
|
||||
return recTitle
|
||||
? { ...recTitle, source: rec.source, rank: rec.rank }
|
||||
: null;
|
||||
}),
|
||||
)
|
||||
).filter(Boolean);
|
||||
|
||||
return NextResponse.json(results);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function GET(
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
let title = db.select().from(titles).where(eq(titles.id, id)).get();
|
||||
let title = await db.select().from(titles).where(eq(titles.id, id)).get();
|
||||
if (!title)
|
||||
return NextResponse.json({ error: "Title not found" }, { status: 404 });
|
||||
|
||||
@@ -19,7 +19,8 @@ export async function GET(
|
||||
if (title.type === "tv" && !title.lastFetchedAt) {
|
||||
try {
|
||||
const show = await getTvDetails(title.tmdbId);
|
||||
db.update(titles)
|
||||
await db
|
||||
.update(titles)
|
||||
.set({
|
||||
overview: show.overview,
|
||||
posterPath: show.poster_path,
|
||||
@@ -30,7 +31,9 @@ export async function GET(
|
||||
.where(eq(titles.id, id))
|
||||
.run();
|
||||
await refreshTvChildren(id, title.tmdbId, show.number_of_seasons);
|
||||
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
|
||||
title =
|
||||
(await db.select().from(titles).where(eq(titles.id, id)).get()) ??
|
||||
title;
|
||||
} catch {
|
||||
// Continue with whatever data we have
|
||||
}
|
||||
@@ -55,25 +58,27 @@ export async function GET(
|
||||
}> = [];
|
||||
|
||||
if (title.type === "tv") {
|
||||
const seasonRows = db
|
||||
const seasonRows = await db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, title.id))
|
||||
.orderBy(seasons.seasonNumber)
|
||||
.all();
|
||||
|
||||
titleSeasons = seasonRows.map((s) => ({
|
||||
...s,
|
||||
episodes: db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.seasonId, s.id))
|
||||
.orderBy(episodes.episodeNumber)
|
||||
.all(),
|
||||
}));
|
||||
titleSeasons = await Promise.all(
|
||||
seasonRows.map(async (s) => ({
|
||||
...s,
|
||||
episodes: await db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.seasonId, s.id))
|
||||
.orderBy(episodes.episodeNumber)
|
||||
.all(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const availability = db
|
||||
const availability = await db
|
||||
.select()
|
||||
.from(availabilityOffers)
|
||||
.where(eq(availabilityOffers.titleId, title.id))
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function GET(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const info = getUserTitleInfo(session.user.id, id);
|
||||
const info = await getUserTitleInfo(session.user.id, id);
|
||||
return NextResponse.json(info);
|
||||
}
|
||||
|
||||
@@ -38,9 +38,9 @@ export async function POST(
|
||||
const { status } = body;
|
||||
|
||||
if (status === null || status === undefined) {
|
||||
removeTitleStatus(session.user.id, id);
|
||||
await removeTitleStatus(session.user.id, id);
|
||||
} else {
|
||||
setTitleStatus(session.user.id, id, status);
|
||||
await setTitleStatus(session.user.id, id, status);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": ["**", "!node_modules", "!.next", "!dist", "!build"]
|
||||
"includes": ["**", "!node_modules", "!.next", "!dist", "!build", "!drizzle"]
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
couch-potato:
|
||||
build: .
|
||||
image: couch-potato
|
||||
container_name: couch-potato
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- couch-potato-data:/data
|
||||
environment:
|
||||
- DATABASE_URL=file:/data/sqlite.db
|
||||
- TMDB_API_KEY=${TMDB_API_KEY}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000}
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
start_period: 30s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
couch-potato-data:
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "sqlite",
|
||||
dialect: "turso",
|
||||
schema: "./lib/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "sqlite.db",
|
||||
url: process.env.DATABASE_URL ?? "file:sqlite.db",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
CREATE TABLE `account` (
|
||||
`id` text PRIMARY KEY,
|
||||
`userId` text NOT NULL,
|
||||
`accountId` text NOT NULL,
|
||||
`providerId` text NOT NULL,
|
||||
`accessToken` text,
|
||||
`refreshToken` text,
|
||||
`accessTokenExpiresAt` integer,
|
||||
`refreshTokenExpiresAt` integer,
|
||||
`scope` text,
|
||||
`password` text,
|
||||
`createdAt` integer NOT NULL,
|
||||
`updatedAt` integer NOT NULL,
|
||||
CONSTRAINT `fk_account_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `availabilityOffers` (
|
||||
`titleId` text NOT NULL,
|
||||
`region` text DEFAULT 'US' NOT NULL,
|
||||
`providerId` integer NOT NULL,
|
||||
`providerName` text NOT NULL,
|
||||
`logoPath` text,
|
||||
`offerType` text NOT NULL,
|
||||
`link` text,
|
||||
`lastFetchedAt` integer,
|
||||
CONSTRAINT `fk_availabilityOffers_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `episodes` (
|
||||
`id` text PRIMARY KEY,
|
||||
`seasonId` text NOT NULL,
|
||||
`episodeNumber` integer NOT NULL,
|
||||
`name` text,
|
||||
`overview` text,
|
||||
`stillPath` text,
|
||||
`airDate` text,
|
||||
`runtimeMinutes` integer,
|
||||
CONSTRAINT `fk_episodes_seasonId_seasons_id_fk` FOREIGN KEY (`seasonId`) REFERENCES `seasons`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `seasons` (
|
||||
`id` text PRIMARY KEY,
|
||||
`titleId` text NOT NULL,
|
||||
`seasonNumber` integer NOT NULL,
|
||||
`name` text,
|
||||
`overview` text,
|
||||
`posterPath` text,
|
||||
`airDate` text,
|
||||
`lastFetchedAt` integer,
|
||||
CONSTRAINT `fk_seasons_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `session` (
|
||||
`id` text PRIMARY KEY,
|
||||
`userId` text NOT NULL,
|
||||
`token` text NOT NULL UNIQUE,
|
||||
`expiresAt` integer NOT NULL,
|
||||
`ipAddress` text,
|
||||
`userAgent` text,
|
||||
`createdAt` integer NOT NULL,
|
||||
`updatedAt` integer NOT NULL,
|
||||
CONSTRAINT `fk_session_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `titleRecommendations` (
|
||||
`titleId` text NOT NULL,
|
||||
`recommendedTitleId` text NOT NULL,
|
||||
`source` text NOT NULL,
|
||||
`rank` integer NOT NULL,
|
||||
`lastFetchedAt` integer,
|
||||
CONSTRAINT `fk_titleRecommendations_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_titleRecommendations_recommendedTitleId_titles_id_fk` FOREIGN KEY (`recommendedTitleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `titles` (
|
||||
`id` text PRIMARY KEY,
|
||||
`tmdbId` integer NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`originalTitle` text,
|
||||
`overview` text,
|
||||
`releaseDate` text,
|
||||
`firstAirDate` text,
|
||||
`posterPath` text,
|
||||
`backdropPath` text,
|
||||
`popularity` real,
|
||||
`voteAverage` real,
|
||||
`voteCount` integer,
|
||||
`status` text,
|
||||
`lastFetchedAt` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user` (
|
||||
`id` text PRIMARY KEY,
|
||||
`name` text NOT NULL,
|
||||
`email` text NOT NULL UNIQUE,
|
||||
`emailVerified` integer DEFAULT false NOT NULL,
|
||||
`image` text,
|
||||
`createdAt` integer NOT NULL,
|
||||
`updatedAt` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `userEpisodeWatches` (
|
||||
`id` text PRIMARY KEY,
|
||||
`userId` text NOT NULL,
|
||||
`episodeId` text NOT NULL,
|
||||
`watchedAt` integer NOT NULL,
|
||||
`source` text DEFAULT 'manual' NOT NULL,
|
||||
CONSTRAINT `fk_userEpisodeWatches_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_userEpisodeWatches_episodeId_episodes_id_fk` FOREIGN KEY (`episodeId`) REFERENCES `episodes`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `userMovieWatches` (
|
||||
`id` text PRIMARY KEY,
|
||||
`userId` text NOT NULL,
|
||||
`titleId` text NOT NULL,
|
||||
`watchedAt` integer NOT NULL,
|
||||
`source` text DEFAULT 'manual' NOT NULL,
|
||||
CONSTRAINT `fk_userMovieWatches_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_userMovieWatches_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `userRatings` (
|
||||
`userId` text NOT NULL,
|
||||
`titleId` text NOT NULL,
|
||||
`ratingStars` integer NOT NULL,
|
||||
`ratedAt` integer NOT NULL,
|
||||
CONSTRAINT `fk_userRatings_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_userRatings_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `userTitleStatus` (
|
||||
`userId` text NOT NULL,
|
||||
`titleId` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`addedAt` integer NOT NULL,
|
||||
`updatedAt` integer NOT NULL,
|
||||
CONSTRAINT `fk_userTitleStatus_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_userTitleStatus_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `verification` (
|
||||
`id` text PRIMARY KEY,
|
||||
`identifier` text NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`expiresAt` integer NOT NULL,
|
||||
`createdAt` integer,
|
||||
`updatedAt` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `availabilityOffers_unique` ON `availabilityOffers` (`titleId`,`region`,`providerId`,`offerType`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `episodes_seasonId_episodeNumber` ON `episodes` (`seasonId`,`episodeNumber`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `seasons_titleId_seasonNumber` ON `seasons` (`titleId`,`seasonNumber`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `titleRecommendations_unique` ON `titleRecommendations` (`titleId`,`recommendedTitleId`,`source`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `titles_tmdbId_unique` ON `titles` (`tmdbId`);--> statement-breakpoint
|
||||
CREATE INDEX `titles_type_releaseDate` ON `titles` (`type`,`releaseDate`);--> statement-breakpoint
|
||||
CREATE INDEX `titles_type_firstAirDate` ON `titles` (`type`,`firstAirDate`);--> statement-breakpoint
|
||||
CREATE INDEX `userEpisodeWatches_userId_watchedAt` ON `userEpisodeWatches` (`userId`,`watchedAt`);--> statement-breakpoint
|
||||
CREATE INDEX `userEpisodeWatches_episodeId` ON `userEpisodeWatches` (`episodeId`);--> statement-breakpoint
|
||||
CREATE INDEX `userMovieWatches_userId_watchedAt` ON `userMovieWatches` (`userId`,`watchedAt`);--> statement-breakpoint
|
||||
CREATE INDEX `userMovieWatches_titleId` ON `userMovieWatches` (`titleId`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `userRatings_userId_titleId` ON `userRatings` (`userId`,`titleId`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `userTitleStatus_userId_titleId` ON `userTitleStatus` (`userId`,`titleId`);--> statement-breakpoint
|
||||
CREATE INDEX `userTitleStatus_userId_status` ON `userTitleStatus` (`userId`,`status`);
|
||||
File diff suppressed because it is too large
Load Diff
+25
-6
@@ -3,11 +3,30 @@ export async function onRequestError() {
|
||||
}
|
||||
|
||||
export async function register() {
|
||||
if (
|
||||
process.env.NEXT_RUNTIME === "nodejs" &&
|
||||
process.env.NODE_ENV === "production"
|
||||
) {
|
||||
const { initJobs } = await import("@/lib/jobs/init");
|
||||
initJobs();
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
// Run database migrations on startup
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
const { runMigrations } = await import("@/lib/db/migrate");
|
||||
await runMigrations();
|
||||
|
||||
const { initJobs } = await import("@/lib/jobs/init");
|
||||
initJobs();
|
||||
|
||||
// Graceful shutdown — stop jobs and close DB on container stop
|
||||
const { scheduler } = await import("@/lib/jobs/scheduler");
|
||||
const { client } = await import("@/lib/db/client");
|
||||
|
||||
const shutdown = () => {
|
||||
console.log("[shutdown] Stopping scheduler...");
|
||||
scheduler.stop();
|
||||
console.log("[shutdown] Closing database...");
|
||||
client.close();
|
||||
console.log("[shutdown] Clean shutdown complete");
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-10
@@ -1,22 +1,24 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { createClient } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import * as schema from "./schema";
|
||||
|
||||
const globalForDb = globalThis as unknown as {
|
||||
_db: ReturnType<typeof drizzle> | undefined;
|
||||
_sqlite: Database.Database | undefined;
|
||||
_client: ReturnType<typeof createClient> | undefined;
|
||||
};
|
||||
|
||||
if (!globalForDb._sqlite) {
|
||||
globalForDb._sqlite = new Database(process.env.DATABASE_URL ?? "sqlite.db");
|
||||
globalForDb._sqlite.pragma("journal_mode = WAL");
|
||||
globalForDb._sqlite.pragma("foreign_keys = ON");
|
||||
globalForDb._sqlite.pragma("busy_timeout = 5000");
|
||||
if (!globalForDb._client) {
|
||||
globalForDb._client = createClient({
|
||||
url: process.env.DATABASE_URL ?? "file:sqlite.db",
|
||||
});
|
||||
globalForDb._client.execute("PRAGMA journal_mode = WAL");
|
||||
globalForDb._client.execute("PRAGMA foreign_keys = ON");
|
||||
globalForDb._client.execute("PRAGMA busy_timeout = 5000");
|
||||
}
|
||||
|
||||
if (!globalForDb._db) {
|
||||
globalForDb._db = drizzle(globalForDb._sqlite, { schema });
|
||||
globalForDb._db = drizzle({ client: globalForDb._client, schema });
|
||||
}
|
||||
|
||||
export const db = globalForDb._db;
|
||||
export const sqlite = globalForDb._sqlite;
|
||||
export const client = globalForDb._client;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { db } from "./client";
|
||||
|
||||
export async function runMigrations() {
|
||||
console.log("[migrate] Running database migrations...");
|
||||
await migrate(db, { migrationsFolder: "./drizzle" });
|
||||
console.log("[migrate] Database migrations complete");
|
||||
}
|
||||
+13
-13
@@ -23,24 +23,24 @@ function delay(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function getLibraryTitleIds(): string[] {
|
||||
return db
|
||||
async function getLibraryTitleIds(): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ titleId: userTitleStatus.titleId })
|
||||
.from(userTitleStatus)
|
||||
.groupBy(userTitleStatus.titleId)
|
||||
.all()
|
||||
.map((r) => r.titleId);
|
||||
.all();
|
||||
return rows.map((r) => r.titleId);
|
||||
}
|
||||
|
||||
// Refresh titles where lastFetchedAt is stale
|
||||
async function nightlyRefreshLibrary() {
|
||||
const libraryIds = getLibraryTitleIds();
|
||||
const libraryIds = await getLibraryTitleIds();
|
||||
const libraryStale = new Date(Date.now() - 7 * DAY);
|
||||
const nonLibraryStale = new Date(Date.now() - 30 * DAY);
|
||||
|
||||
// Library titles: 7 days
|
||||
for (const titleId of libraryIds) {
|
||||
const t = db
|
||||
const t = await db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(
|
||||
@@ -54,7 +54,7 @@ async function nightlyRefreshLibrary() {
|
||||
}
|
||||
|
||||
// Non-library titles: 30 days
|
||||
const nonLibrary = db
|
||||
const nonLibrary = await db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(
|
||||
@@ -76,12 +76,12 @@ async function nightlyRefreshLibrary() {
|
||||
|
||||
// Refresh availability for library titles where stale
|
||||
async function refreshAvailabilityJob() {
|
||||
const libraryIds = getLibraryTitleIds();
|
||||
const libraryIds = await getLibraryTitleIds();
|
||||
const stale = new Date(Date.now() - DAY);
|
||||
|
||||
for (const titleId of libraryIds) {
|
||||
// Check if any offer is stale
|
||||
const offer = db
|
||||
const offer = await db
|
||||
.select()
|
||||
.from(availabilityOffers)
|
||||
.where(
|
||||
@@ -93,7 +93,7 @@ async function refreshAvailabilityJob() {
|
||||
.get();
|
||||
|
||||
// Also handle titles with no offers yet
|
||||
const anyOffer = db
|
||||
const anyOffer = await db
|
||||
.select()
|
||||
.from(availabilityOffers)
|
||||
.where(eq(availabilityOffers.titleId, titleId))
|
||||
@@ -108,7 +108,7 @@ async function refreshAvailabilityJob() {
|
||||
|
||||
// Refresh recommendations for recently active titles
|
||||
async function refreshRecommendationsJob() {
|
||||
const libraryIds = getLibraryTitleIds();
|
||||
const libraryIds = await getLibraryTitleIds();
|
||||
|
||||
for (const titleId of libraryIds) {
|
||||
await refreshRecommendations(titleId);
|
||||
@@ -121,7 +121,7 @@ async function refreshTvChildrenJob() {
|
||||
const returningStatuses = ["Returning Series", "In Production"];
|
||||
const stale = new Date(Date.now() - 7 * DAY);
|
||||
|
||||
const tvShows = db
|
||||
const tvShows = await db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(
|
||||
@@ -135,7 +135,7 @@ async function refreshTvChildrenJob() {
|
||||
|
||||
for (const show of tvShows) {
|
||||
// Check if seasons are stale
|
||||
const staleSeason = db
|
||||
const staleSeason = await db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(
|
||||
|
||||
@@ -4,7 +4,11 @@ import { availabilityOffers, titles } from "@/lib/db/schema";
|
||||
import { getWatchProviders } from "@/lib/tmdb/client";
|
||||
|
||||
export async function refreshAvailability(titleId: string) {
|
||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||
const title = await db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.id, titleId))
|
||||
.get();
|
||||
if (!title) return;
|
||||
|
||||
const data = await getWatchProviders(title.tmdbId, title.type);
|
||||
@@ -15,7 +19,8 @@ export async function refreshAvailability(titleId: string) {
|
||||
const offerTypes = ["flatrate", "rent", "buy", "free", "ads"] as const;
|
||||
|
||||
// Delete existing offers for this title+region
|
||||
db.delete(availabilityOffers)
|
||||
await db
|
||||
.delete(availabilityOffers)
|
||||
.where(
|
||||
and(
|
||||
eq(availabilityOffers.titleId, titleId),
|
||||
@@ -29,7 +34,8 @@ export async function refreshAvailability(titleId: string) {
|
||||
if (!providers) continue;
|
||||
|
||||
for (const p of providers) {
|
||||
db.insert(availabilityOffers)
|
||||
await db
|
||||
.insert(availabilityOffers)
|
||||
.values({
|
||||
titleId,
|
||||
region: "US",
|
||||
@@ -46,7 +52,7 @@ export async function refreshAvailability(titleId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getAvailability(titleId: string) {
|
||||
export async function getAvailability(titleId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(availabilityOffers)
|
||||
|
||||
+54
-46
@@ -32,11 +32,11 @@ export interface ContinueWatchingItem {
|
||||
watchedEpisodes: number;
|
||||
}
|
||||
|
||||
export function getContinueWatchingFeed(
|
||||
export async function getContinueWatchingFeed(
|
||||
userId: string,
|
||||
): ContinueWatchingItem[] {
|
||||
): Promise<ContinueWatchingItem[]> {
|
||||
// Get in-progress TV shows
|
||||
const inProgress = db
|
||||
const inProgress = await db
|
||||
.select({
|
||||
titleId: userTitleStatus.titleId,
|
||||
updatedAt: userTitleStatus.updatedAt,
|
||||
@@ -54,7 +54,7 @@ export function getContinueWatchingFeed(
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
for (const row of inProgress) {
|
||||
const title = db
|
||||
const title = await db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(and(eq(titles.id, row.titleId), eq(titles.type, "tv")))
|
||||
@@ -62,7 +62,7 @@ export function getContinueWatchingFeed(
|
||||
if (!title) continue;
|
||||
|
||||
// Get all seasons for this title, ordered
|
||||
const titleSeasons = db
|
||||
const titleSeasons = await db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, title.id))
|
||||
@@ -77,7 +77,7 @@ export function getContinueWatchingFeed(
|
||||
|
||||
// Get most recent watch for this show
|
||||
for (const s of titleSeasons) {
|
||||
const eps = db
|
||||
const eps = await db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.seasonId, s.id))
|
||||
@@ -87,7 +87,7 @@ export function getContinueWatchingFeed(
|
||||
totalEpisodes += eps.length;
|
||||
|
||||
for (const ep of eps) {
|
||||
const watch = db
|
||||
const watch = await db
|
||||
.select()
|
||||
.from(userEpisodeWatches)
|
||||
.where(
|
||||
@@ -146,10 +146,10 @@ export function getContinueWatchingFeed(
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedFunctionParameters: days reserved for future date filtering
|
||||
export function getNewAvailableFeed(userId: string, days = 14) {
|
||||
export async function getNewAvailableFeed(userId: string, days = 14) {
|
||||
// Get titles the user has in any status that have availability offers
|
||||
// and recent release/air dates
|
||||
const results = db
|
||||
const results = await db
|
||||
.select({
|
||||
titleId: titles.id,
|
||||
title: titles.title,
|
||||
@@ -177,46 +177,52 @@ export function getNewAvailableFeed(userId: string, days = 14) {
|
||||
return results;
|
||||
}
|
||||
|
||||
export function getRecommendationsFeed(userId: string) {
|
||||
export async function getRecommendationsFeed(userId: string) {
|
||||
// Get recommendations from user's highly-rated or completed titles
|
||||
const userCompletedOrRated = db
|
||||
.select({ titleId: userTitleStatus.titleId })
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.status, "completed"),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
.map((r) => r.titleId);
|
||||
const userCompletedOrRated = (
|
||||
await db
|
||||
.select({ titleId: userTitleStatus.titleId })
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(userTitleStatus.status, "completed"),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
).map((r) => r.titleId);
|
||||
|
||||
const ratedIds = db
|
||||
.select({ titleId: userRatings.titleId })
|
||||
.from(userRatings)
|
||||
.where(
|
||||
and(eq(userRatings.userId, userId), sql`${userRatings.ratingStars} >= 4`),
|
||||
)
|
||||
.all()
|
||||
.map((r) => r.titleId);
|
||||
const ratedIds = (
|
||||
await db
|
||||
.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])];
|
||||
if (sourceIds.length === 0) return [];
|
||||
|
||||
// Get all tracked title IDs to exclude
|
||||
const trackedIds = new Set(
|
||||
db
|
||||
.select({ titleId: userTitleStatus.titleId })
|
||||
.from(userTitleStatus)
|
||||
.where(eq(userTitleStatus.userId, userId))
|
||||
.all()
|
||||
.map((r) => r.titleId),
|
||||
(
|
||||
await db
|
||||
.select({ titleId: userTitleStatus.titleId })
|
||||
.from(userTitleStatus)
|
||||
.where(eq(userTitleStatus.userId, userId))
|
||||
.all()
|
||||
).map((r) => r.titleId),
|
||||
);
|
||||
|
||||
const recs: Map<string, { titleId: string; score: number }> = new Map();
|
||||
|
||||
for (const sourceId of sourceIds) {
|
||||
const recRows = db
|
||||
const recRows = await db
|
||||
.select({
|
||||
recommendedTitleId: titleRecommendations.recommendedTitleId,
|
||||
rank: titleRecommendations.rank,
|
||||
@@ -244,14 +250,16 @@ export function getRecommendationsFeed(userId: string) {
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 20);
|
||||
|
||||
return sorted
|
||||
.map((r) => {
|
||||
const title = db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.id, r.titleId))
|
||||
.get();
|
||||
return title;
|
||||
})
|
||||
.filter(Boolean);
|
||||
return (
|
||||
await Promise.all(
|
||||
sorted.map(async (r) => {
|
||||
const title = await db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.id, r.titleId))
|
||||
.get();
|
||||
return title;
|
||||
}),
|
||||
)
|
||||
).filter(Boolean);
|
||||
}
|
||||
|
||||
+39
-23
@@ -16,7 +16,7 @@ import {
|
||||
import { refreshAvailability } from "./availability";
|
||||
|
||||
export async function importTitle(tmdbId: number, type: "movie" | "tv") {
|
||||
const existing = db
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.tmdbId, tmdbId))
|
||||
@@ -26,15 +26,18 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
|
||||
// They may be missing if a prior fetch failed or the title was created
|
||||
// as a shell by the recommendations system (lastFetchedAt: null).
|
||||
if (existing.type === "tv") {
|
||||
const seasonCount = db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, existing.id))
|
||||
.all().length;
|
||||
const seasonCount = (
|
||||
await db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, existing.id))
|
||||
.all()
|
||||
).length;
|
||||
if (seasonCount === 0) {
|
||||
const show = await getTvDetails(tmdbId);
|
||||
if (!existing.lastFetchedAt) {
|
||||
db.update(titles)
|
||||
await db
|
||||
.update(titles)
|
||||
.set({
|
||||
overview: show.overview,
|
||||
posterPath: show.poster_path,
|
||||
@@ -58,7 +61,7 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
|
||||
|
||||
if (type === "movie") {
|
||||
const movie = await getMovieDetails(tmdbId);
|
||||
const row = db
|
||||
const row = await db
|
||||
.insert(titles)
|
||||
.values({
|
||||
tmdbId: movie.id,
|
||||
@@ -84,7 +87,7 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
|
||||
}
|
||||
|
||||
const show = await getTvDetails(tmdbId);
|
||||
const row = db
|
||||
const row = await db
|
||||
.insert(titles)
|
||||
.values({
|
||||
tmdbId: show.id,
|
||||
@@ -112,14 +115,19 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
|
||||
}
|
||||
|
||||
export async function refreshTitle(titleId: string) {
|
||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||
const title = await db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.id, titleId))
|
||||
.get();
|
||||
if (!title) return null;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (title.type === "movie") {
|
||||
const movie = await getMovieDetails(title.tmdbId);
|
||||
db.update(titles)
|
||||
await db
|
||||
.update(titles)
|
||||
.set({
|
||||
title: movie.title,
|
||||
originalTitle: movie.original_title,
|
||||
@@ -137,7 +145,8 @@ export async function refreshTitle(titleId: string) {
|
||||
.run();
|
||||
} else {
|
||||
const show = await getTvDetails(title.tmdbId);
|
||||
db.update(titles)
|
||||
await db
|
||||
.update(titles)
|
||||
.set({
|
||||
title: show.name,
|
||||
originalTitle: show.original_name,
|
||||
@@ -173,7 +182,7 @@ export async function refreshTvChildren(
|
||||
try {
|
||||
const seasonData = await getTvSeasonDetails(tmdbId, sn);
|
||||
|
||||
const seasonRow = db
|
||||
const seasonRow = await db
|
||||
.insert(seasons)
|
||||
.values({
|
||||
titleId,
|
||||
@@ -198,7 +207,8 @@ export async function refreshTvChildren(
|
||||
.get();
|
||||
|
||||
for (const ep of seasonData.episodes) {
|
||||
db.insert(episodes)
|
||||
await db
|
||||
.insert(episodes)
|
||||
.values({
|
||||
seasonId: seasonRow.id,
|
||||
episodeNumber: ep.episode_number,
|
||||
@@ -229,7 +239,11 @@ export async function refreshTvChildren(
|
||||
}
|
||||
|
||||
export async function refreshRecommendations(titleId: string) {
|
||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||
const title = await db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.id, titleId))
|
||||
.get();
|
||||
if (!title) return;
|
||||
|
||||
const now = new Date();
|
||||
@@ -247,7 +261,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
if (type !== "movie" && type !== "tv") continue;
|
||||
|
||||
// Minimal upsert of the recommended title
|
||||
const existing = db
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.tmdbId, r.id))
|
||||
@@ -256,7 +270,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
if (existing) {
|
||||
recTitleId = existing.id;
|
||||
} else {
|
||||
const row = db
|
||||
const row = await db
|
||||
.insert(titles)
|
||||
.values({
|
||||
tmdbId: r.id,
|
||||
@@ -277,7 +291,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
.returning()
|
||||
.get();
|
||||
if (!row) {
|
||||
const found = db
|
||||
const found = await db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.tmdbId, r.id))
|
||||
@@ -289,7 +303,8 @@ export async function refreshRecommendations(titleId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
db.insert(titleRecommendations)
|
||||
await db
|
||||
.insert(titleRecommendations)
|
||||
.values({
|
||||
titleId,
|
||||
recommendedTitleId: recTitleId,
|
||||
@@ -314,7 +329,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
const type = r.media_type ?? title.type;
|
||||
if (type !== "movie" && type !== "tv") continue;
|
||||
|
||||
const existing = db
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.tmdbId, r.id))
|
||||
@@ -323,7 +338,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
if (existing) {
|
||||
recTitleId = existing.id;
|
||||
} else {
|
||||
const row = db
|
||||
const row = await db
|
||||
.insert(titles)
|
||||
.values({
|
||||
tmdbId: r.id,
|
||||
@@ -344,7 +359,7 @@ export async function refreshRecommendations(titleId: string) {
|
||||
.returning()
|
||||
.get();
|
||||
if (!row) {
|
||||
const found = db
|
||||
const found = await db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.tmdbId, r.id))
|
||||
@@ -356,7 +371,8 @@ export async function refreshRecommendations(titleId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
db.insert(titleRecommendations)
|
||||
await db
|
||||
.insert(titleRecommendations)
|
||||
.values({
|
||||
titleId,
|
||||
recommendedTitleId: recTitleId,
|
||||
|
||||
+70
-49
@@ -10,13 +10,14 @@ import {
|
||||
userTitleStatus,
|
||||
} from "@/lib/db/schema";
|
||||
|
||||
export function setTitleStatus(
|
||||
export async function setTitleStatus(
|
||||
userId: string,
|
||||
titleId: string,
|
||||
status: "watchlist" | "in_progress" | "completed",
|
||||
) {
|
||||
const now = new Date();
|
||||
db.insert(userTitleStatus)
|
||||
await db
|
||||
.insert(userTitleStatus)
|
||||
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: [userTitleStatus.userId, userTitleStatus.titleId],
|
||||
@@ -25,12 +26,13 @@ export function setTitleStatus(
|
||||
.run();
|
||||
|
||||
if (status === "completed") {
|
||||
markAllEpisodesWatched(userId, titleId);
|
||||
await markAllEpisodesWatched(userId, titleId);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeTitleStatus(userId: string, titleId: string) {
|
||||
db.delete(userTitleStatus)
|
||||
export async function removeTitleStatus(userId: string, titleId: string) {
|
||||
await db
|
||||
.delete(userTitleStatus)
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
@@ -40,14 +42,15 @@ export function removeTitleStatus(userId: string, titleId: string) {
|
||||
.run();
|
||||
}
|
||||
|
||||
export function logMovieWatch(userId: string, titleId: string) {
|
||||
export async function logMovieWatch(userId: string, titleId: string) {
|
||||
const now = new Date();
|
||||
db.insert(userMovieWatches)
|
||||
await db
|
||||
.insert(userMovieWatches)
|
||||
.values({ userId, titleId, watchedAt: now, source: "manual" })
|
||||
.run();
|
||||
|
||||
// Auto-set status to completed
|
||||
const existing = db
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
@@ -59,22 +62,27 @@ export function logMovieWatch(userId: string, titleId: string) {
|
||||
.get();
|
||||
|
||||
if (!existing) {
|
||||
setTitleStatus(userId, titleId, "completed");
|
||||
await setTitleStatus(userId, titleId, "completed");
|
||||
} else if (existing.status !== "completed") {
|
||||
setTitleStatus(userId, titleId, "completed");
|
||||
await setTitleStatus(userId, titleId, "completed");
|
||||
}
|
||||
}
|
||||
|
||||
export function logEpisodeWatch(userId: string, episodeId: string) {
|
||||
export async function logEpisodeWatch(userId: string, episodeId: string) {
|
||||
const now = new Date();
|
||||
db.insert(userEpisodeWatches)
|
||||
await db
|
||||
.insert(userEpisodeWatches)
|
||||
.values({ userId, episodeId, watchedAt: now, source: "manual" })
|
||||
.run();
|
||||
|
||||
// Find the title for this episode
|
||||
const ep = db.select().from(episodes).where(eq(episodes.id, episodeId)).get();
|
||||
const ep = await db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.id, episodeId))
|
||||
.get();
|
||||
if (!ep) return;
|
||||
const season = db
|
||||
const season = await db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.id, ep.seasonId))
|
||||
@@ -83,7 +91,7 @@ export function logEpisodeWatch(userId: string, episodeId: string) {
|
||||
const titleId = season.titleId;
|
||||
|
||||
// Auto-set status to in_progress if not set
|
||||
const existing = db
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
@@ -95,33 +103,37 @@ export function logEpisodeWatch(userId: string, episodeId: string) {
|
||||
.get();
|
||||
|
||||
if (!existing) {
|
||||
setTitleStatus(userId, titleId, "in_progress");
|
||||
await setTitleStatus(userId, titleId, "in_progress");
|
||||
}
|
||||
|
||||
// Check if all episodes are watched -> auto-complete
|
||||
checkAllEpisodesWatched(userId, titleId);
|
||||
await checkAllEpisodesWatched(userId, titleId);
|
||||
}
|
||||
|
||||
function markAllEpisodesWatched(userId: string, titleId: string) {
|
||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||
async function markAllEpisodesWatched(userId: string, titleId: string) {
|
||||
const title = await db
|
||||
.select()
|
||||
.from(titles)
|
||||
.where(eq(titles.id, titleId))
|
||||
.get();
|
||||
if (!title || title.type !== "tv") return;
|
||||
|
||||
const now = new Date();
|
||||
const allSeasons = db
|
||||
const allSeasons = await db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
.all();
|
||||
|
||||
for (const s of allSeasons) {
|
||||
const eps = db
|
||||
const eps = await db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.seasonId, s.id))
|
||||
.all();
|
||||
|
||||
for (const ep of eps) {
|
||||
const existing = db
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(userEpisodeWatches)
|
||||
.where(
|
||||
@@ -132,7 +144,8 @@ function markAllEpisodesWatched(userId: string, titleId: string) {
|
||||
)
|
||||
.get();
|
||||
if (!existing) {
|
||||
db.insert(userEpisodeWatches)
|
||||
await db
|
||||
.insert(userEpisodeWatches)
|
||||
.values({
|
||||
userId,
|
||||
episodeId: ep.id,
|
||||
@@ -145,8 +158,8 @@ function markAllEpisodesWatched(userId: string, titleId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function checkAllEpisodesWatched(userId: string, titleId: string) {
|
||||
const allSeasons = db
|
||||
async function checkAllEpisodesWatched(userId: string, titleId: string) {
|
||||
const allSeasons = await db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
@@ -156,7 +169,7 @@ function checkAllEpisodesWatched(userId: string, titleId: string) {
|
||||
let watchedEpisodes = 0;
|
||||
|
||||
for (const s of allSeasons) {
|
||||
const eps = db
|
||||
const eps = await db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.seasonId, s.id))
|
||||
@@ -164,7 +177,7 @@ function checkAllEpisodesWatched(userId: string, titleId: string) {
|
||||
totalEpisodes += eps.length;
|
||||
|
||||
for (const ep of eps) {
|
||||
const watch = db
|
||||
const watch = await db
|
||||
.select()
|
||||
.from(userEpisodeWatches)
|
||||
.where(
|
||||
@@ -179,12 +192,13 @@ function checkAllEpisodesWatched(userId: string, titleId: string) {
|
||||
}
|
||||
|
||||
if (totalEpisodes > 0 && watchedEpisodes >= totalEpisodes) {
|
||||
setTitleStatus(userId, titleId, "completed");
|
||||
await setTitleStatus(userId, titleId, "completed");
|
||||
}
|
||||
}
|
||||
|
||||
export function unwatchEpisode(userId: string, episodeId: string) {
|
||||
db.delete(userEpisodeWatches)
|
||||
export async function unwatchEpisode(userId: string, episodeId: string) {
|
||||
await db
|
||||
.delete(userEpisodeWatches)
|
||||
.where(
|
||||
and(
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
@@ -194,16 +208,20 @@ export function unwatchEpisode(userId: string, episodeId: string) {
|
||||
.run();
|
||||
|
||||
// Find parent title and downgrade from completed to in_progress
|
||||
const ep = db.select().from(episodes).where(eq(episodes.id, episodeId)).get();
|
||||
const ep = await db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.id, episodeId))
|
||||
.get();
|
||||
if (!ep) return;
|
||||
const season = db
|
||||
const season = await db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.id, ep.seasonId))
|
||||
.get();
|
||||
if (!season) return;
|
||||
|
||||
const existing = db
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
@@ -215,12 +233,12 @@ export function unwatchEpisode(userId: string, episodeId: string) {
|
||||
.get();
|
||||
|
||||
if (existing?.status === "completed") {
|
||||
setTitleStatus(userId, season.titleId, "in_progress");
|
||||
await setTitleStatus(userId, season.titleId, "in_progress");
|
||||
}
|
||||
}
|
||||
|
||||
export function unwatchSeason(userId: string, seasonId: string) {
|
||||
const seasonEps = db
|
||||
export async function unwatchSeason(userId: string, seasonId: string) {
|
||||
const seasonEps = await db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.seasonId, seasonId))
|
||||
@@ -228,7 +246,8 @@ export function unwatchSeason(userId: string, seasonId: string) {
|
||||
|
||||
const epIds = seasonEps.map((ep) => ep.id);
|
||||
if (epIds.length > 0) {
|
||||
db.delete(userEpisodeWatches)
|
||||
await db
|
||||
.delete(userEpisodeWatches)
|
||||
.where(
|
||||
and(
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
@@ -239,14 +258,14 @@ export function unwatchSeason(userId: string, seasonId: string) {
|
||||
}
|
||||
|
||||
// Find parent title and downgrade from completed to in_progress
|
||||
const season = db
|
||||
const season = await db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.id, seasonId))
|
||||
.get();
|
||||
if (!season) return;
|
||||
|
||||
const existing = db
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
@@ -258,25 +277,27 @@ export function unwatchSeason(userId: string, seasonId: string) {
|
||||
.get();
|
||||
|
||||
if (existing?.status === "completed") {
|
||||
setTitleStatus(userId, season.titleId, "in_progress");
|
||||
await setTitleStatus(userId, season.titleId, "in_progress");
|
||||
}
|
||||
}
|
||||
|
||||
export function rateTitleStars(
|
||||
export async function rateTitleStars(
|
||||
userId: string,
|
||||
titleId: string,
|
||||
ratingStars: number,
|
||||
) {
|
||||
const now = new Date();
|
||||
if (ratingStars === 0) {
|
||||
db.delete(userRatings)
|
||||
await db
|
||||
.delete(userRatings)
|
||||
.where(
|
||||
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
|
||||
)
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
db.insert(userRatings)
|
||||
await db
|
||||
.insert(userRatings)
|
||||
.values({ userId, titleId, ratingStars, ratedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: [userRatings.userId, userRatings.titleId],
|
||||
@@ -285,8 +306,8 @@ export function rateTitleStars(
|
||||
.run();
|
||||
}
|
||||
|
||||
export function getUserTitleInfo(userId: string, titleId: string) {
|
||||
const status = db
|
||||
export async function getUserTitleInfo(userId: string, titleId: string) {
|
||||
const status = await db
|
||||
.select()
|
||||
.from(userTitleStatus)
|
||||
.where(
|
||||
@@ -297,7 +318,7 @@ export function getUserTitleInfo(userId: string, titleId: string) {
|
||||
)
|
||||
.get();
|
||||
|
||||
const rating = db
|
||||
const rating = await db
|
||||
.select()
|
||||
.from(userRatings)
|
||||
.where(
|
||||
@@ -306,7 +327,7 @@ export function getUserTitleInfo(userId: string, titleId: string) {
|
||||
.get();
|
||||
|
||||
// Get watched episode IDs for this title
|
||||
const titleSeasons = db
|
||||
const titleSeasons = await db
|
||||
.select()
|
||||
.from(seasons)
|
||||
.where(eq(seasons.titleId, titleId))
|
||||
@@ -314,13 +335,13 @@ export function getUserTitleInfo(userId: string, titleId: string) {
|
||||
|
||||
const watchedEpisodeIds: string[] = [];
|
||||
for (const s of titleSeasons) {
|
||||
const eps = db
|
||||
const eps = await db
|
||||
.select()
|
||||
.from(episodes)
|
||||
.where(eq(episodes.seasonId, s.id))
|
||||
.all();
|
||||
for (const ep of eps) {
|
||||
const watch = db
|
||||
const watch = await db
|
||||
.select()
|
||||
.from(userEpisodeWatches)
|
||||
.where(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
reactCompiler: true,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
|
||||
+9
-8
@@ -8,6 +8,7 @@
|
||||
"start": "next start",
|
||||
"lint": "biome check",
|
||||
"format": "biome format --write",
|
||||
"check-types": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
@@ -15,14 +16,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@libsql/client": "^0.17.0",
|
||||
"@tabler/icons-react": "^3.37.1",
|
||||
"better-auth": "^1.4.19",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"better-auth": "^1.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"drizzle-orm": "1.0.0-beta.15-859cf75",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"motion": "^12.34.3",
|
||||
"next": "16.1.6",
|
||||
@@ -30,24 +31,24 @@
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hotkeys-hook": "^5.2.4",
|
||||
"react-resizable-panels": "^4.6.5",
|
||||
"react-resizable-panels": "^4.7.0",
|
||||
"recharts": "2.15.4",
|
||||
"shadcn": "^3.8.5",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.1.2"
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.4",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^25.3.2",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"drizzle-kit": "1.0.0-beta.15-859cf75",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
|
||||
Generated
+1681
-382
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user