mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 07:25:44 -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:
33 files changed
+3837
-593
No files matched your search
@@ -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
|
TMDB_API_KEY=your_tmdb_api_key_here
|
||||||
|
|
||||||
|
# Random secret for session encryption (min 32 chars)
|
||||||
BETTER_AUTH_SECRET=your_secret_here
|
BETTER_AUTH_SECRET=your_secret_here
|
||||||
|
|
||||||
|
# Public URL of your instance
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
+3
-2
@@ -23,9 +23,10 @@
|
|||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
# sqlite
|
||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-*
|
||||||
*.db-wal
|
|
||||||
|
|
||||||
# debug
|
# debug
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ pnpm db:studio # Open Drizzle Studio (visual DB browser)
|
|||||||
### Stack
|
### Stack
|
||||||
|
|
||||||
- **Framework**: Next.js 16 (App Router), React 19, TypeScript
|
- **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
|
- **Auth**: Better Auth with Drizzle adapter, email/password
|
||||||
- **Styling**: Tailwind CSS v4, shadcn components, dark cinema theme with warm primary accents
|
- **Styling**: Tailwind CSS v4, shadcn components, dark cinema theme with warm primary accents
|
||||||
- **Fonts**: DM Serif Display (display), DM Sans (body), Geist Mono (mono)
|
- **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 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
logEpisodeWatch(session.user.id, id);
|
await logEpisodeWatch(session.user.id, id);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +30,6 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
unwatchEpisode(session.user.id, id);
|
await unwatchEpisode(session.user.id, id);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,6 @@ export async function GET() {
|
|||||||
if (!session)
|
if (!session)
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const feed = getContinueWatchingFeed(session.user.id);
|
const feed = await getContinueWatchingFeed(session.user.id);
|
||||||
return NextResponse.json(feed);
|
return NextResponse.json(feed);
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,6 @@ export async function GET(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const days = Number(req.nextUrl.searchParams.get("days") ?? 14);
|
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);
|
return NextResponse.json(feed);
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,6 @@ export async function GET() {
|
|||||||
if (!session)
|
if (!session)
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const feed = getRecommendationsFeed(session.user.id);
|
const feed = await getRecommendationsFeed(session.user.id);
|
||||||
return NextResponse.json(feed);
|
return NextResponse.json(feed);
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ export async function GET() {
|
|||||||
weekStart.setDate(now.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
|
weekStart.setDate(now.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
|
||||||
weekStart.setHours(0, 0, 0, 0);
|
weekStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const [moviesThisMonth] = db
|
const [moviesThisMonth] = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(userMovieWatches)
|
.from(userMovieWatches)
|
||||||
.where(
|
.where(
|
||||||
@@ -38,7 +38,7 @@ export async function GET() {
|
|||||||
)
|
)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const [episodesThisWeek] = db
|
const [episodesThisWeek] = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(userEpisodeWatches)
|
.from(userEpisodeWatches)
|
||||||
.where(
|
.where(
|
||||||
@@ -49,13 +49,13 @@ export async function GET() {
|
|||||||
)
|
)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const [librarySize] = db
|
const [librarySize] = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(userTitleStatus)
|
.from(userTitleStatus)
|
||||||
.where(eq(userTitleStatus.userId, userId))
|
.where(eq(userTitleStatus.userId, userId))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const [completedCount] = db
|
const [completedCount] = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(userTitleStatus)
|
.from(userTitleStatus)
|
||||||
.where(
|
.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 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
logMovieWatch(session.user.id, id);
|
await logMovieWatch(session.user.id, id);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -18,14 +18,14 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const seasonEps = db
|
const seasonEps = await db
|
||||||
.select()
|
.select()
|
||||||
.from(episodes)
|
.from(episodes)
|
||||||
.where(eq(episodes.seasonId, id))
|
.where(eq(episodes.seasonId, id))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
for (const ep of seasonEps) {
|
for (const ep of seasonEps) {
|
||||||
logEpisodeWatch(session.user.id, ep.id);
|
await logEpisodeWatch(session.user.id, ep.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
@@ -42,6 +42,6 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
unwatchSeason(session.user.id, id);
|
await unwatchSeason(session.user.id, id);
|
||||||
return NextResponse.json({ ok: true });
|
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 });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -9,11 +9,11 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
const { id } = await params;
|
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)
|
if (!title)
|
||||||
return NextResponse.json({ error: "Title not found" }, { status: 404 });
|
return NextResponse.json({ error: "Title not found" }, { status: 404 });
|
||||||
|
|
||||||
const recs = db
|
const recs = await db
|
||||||
.select({
|
.select({
|
||||||
recommendedTitleId: titleRecommendations.recommendedTitleId,
|
recommendedTitleId: titleRecommendations.recommendedTitleId,
|
||||||
source: titleRecommendations.source,
|
source: titleRecommendations.source,
|
||||||
@@ -24,18 +24,20 @@ export async function GET(
|
|||||||
.orderBy(titleRecommendations.rank)
|
.orderBy(titleRecommendations.rank)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const results = recs
|
const results = (
|
||||||
.map((rec) => {
|
await Promise.all(
|
||||||
const recTitle = db
|
recs.map(async (rec) => {
|
||||||
.select()
|
const recTitle = await db
|
||||||
.from(titles)
|
.select()
|
||||||
.where(eq(titles.id, rec.recommendedTitleId))
|
.from(titles)
|
||||||
.get();
|
.where(eq(titles.id, rec.recommendedTitleId))
|
||||||
return recTitle
|
.get();
|
||||||
? { ...recTitle, source: rec.source, rank: rec.rank }
|
return recTitle
|
||||||
: null;
|
? { ...recTitle, source: rec.source, rank: rec.rank }
|
||||||
})
|
: null;
|
||||||
.filter(Boolean);
|
}),
|
||||||
|
)
|
||||||
|
).filter(Boolean);
|
||||||
|
|
||||||
return NextResponse.json(results);
|
return NextResponse.json(results);
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ export async function GET(
|
|||||||
{ params }: { params: Promise<{ id: string }> },
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
) {
|
) {
|
||||||
const { id } = await params;
|
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)
|
if (!title)
|
||||||
return NextResponse.json({ error: "Title not found" }, { status: 404 });
|
return NextResponse.json({ error: "Title not found" }, { status: 404 });
|
||||||
|
|
||||||
@@ -19,7 +19,8 @@ export async function GET(
|
|||||||
if (title.type === "tv" && !title.lastFetchedAt) {
|
if (title.type === "tv" && !title.lastFetchedAt) {
|
||||||
try {
|
try {
|
||||||
const show = await getTvDetails(title.tmdbId);
|
const show = await getTvDetails(title.tmdbId);
|
||||||
db.update(titles)
|
await db
|
||||||
|
.update(titles)
|
||||||
.set({
|
.set({
|
||||||
overview: show.overview,
|
overview: show.overview,
|
||||||
posterPath: show.poster_path,
|
posterPath: show.poster_path,
|
||||||
@@ -30,7 +31,9 @@ export async function GET(
|
|||||||
.where(eq(titles.id, id))
|
.where(eq(titles.id, id))
|
||||||
.run();
|
.run();
|
||||||
await refreshTvChildren(id, title.tmdbId, show.number_of_seasons);
|
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 {
|
} catch {
|
||||||
// Continue with whatever data we have
|
// Continue with whatever data we have
|
||||||
}
|
}
|
||||||
@@ -55,25 +58,27 @@ export async function GET(
|
|||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (title.type === "tv") {
|
if (title.type === "tv") {
|
||||||
const seasonRows = db
|
const seasonRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(seasons)
|
.from(seasons)
|
||||||
.where(eq(seasons.titleId, title.id))
|
.where(eq(seasons.titleId, title.id))
|
||||||
.orderBy(seasons.seasonNumber)
|
.orderBy(seasons.seasonNumber)
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
titleSeasons = seasonRows.map((s) => ({
|
titleSeasons = await Promise.all(
|
||||||
...s,
|
seasonRows.map(async (s) => ({
|
||||||
episodes: db
|
...s,
|
||||||
.select()
|
episodes: await db
|
||||||
.from(episodes)
|
.select()
|
||||||
.where(eq(episodes.seasonId, s.id))
|
.from(episodes)
|
||||||
.orderBy(episodes.episodeNumber)
|
.where(eq(episodes.seasonId, s.id))
|
||||||
.all(),
|
.orderBy(episodes.episodeNumber)
|
||||||
}));
|
.all(),
|
||||||
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const availability = db
|
const availability = await db
|
||||||
.select()
|
.select()
|
||||||
.from(availabilityOffers)
|
.from(availabilityOffers)
|
||||||
.where(eq(availabilityOffers.titleId, title.id))
|
.where(eq(availabilityOffers.titleId, title.id))
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const info = getUserTitleInfo(session.user.id, id);
|
const info = await getUserTitleInfo(session.user.id, id);
|
||||||
return NextResponse.json(info);
|
return NextResponse.json(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +38,9 @@ export async function POST(
|
|||||||
const { status } = body;
|
const { status } = body;
|
||||||
|
|
||||||
if (status === null || status === undefined) {
|
if (status === null || status === undefined) {
|
||||||
removeTitleStatus(session.user.id, id);
|
await removeTitleStatus(session.user.id, id);
|
||||||
} else {
|
} else {
|
||||||
setTitleStatus(session.user.id, id, status);
|
await setTitleStatus(session.user.id, id, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": true,
|
"ignoreUnknown": true,
|
||||||
"includes": ["**", "!node_modules", "!.next", "!dist", "!build"]
|
"includes": ["**", "!node_modules", "!.next", "!dist", "!build", "!drizzle"]
|
||||||
},
|
},
|
||||||
"css": {
|
"css": {
|
||||||
"parser": {
|
"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";
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
dialect: "sqlite",
|
dialect: "turso",
|
||||||
schema: "./lib/db/schema.ts",
|
schema: "./lib/db/schema.ts",
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
dbCredentials: {
|
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() {
|
export async function register() {
|
||||||
if (
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
process.env.NEXT_RUNTIME === "nodejs" &&
|
// Run database migrations on startup
|
||||||
process.env.NODE_ENV === "production"
|
if (process.env.NODE_ENV === "production") {
|
||||||
) {
|
const { runMigrations } = await import("@/lib/db/migrate");
|
||||||
const { initJobs } = await import("@/lib/jobs/init");
|
await runMigrations();
|
||||||
initJobs();
|
|
||||||
|
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 { createClient } from "@libsql/client";
|
||||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
|
|
||||||
const globalForDb = globalThis as unknown as {
|
const globalForDb = globalThis as unknown as {
|
||||||
_db: ReturnType<typeof drizzle> | undefined;
|
_db: ReturnType<typeof drizzle> | undefined;
|
||||||
_sqlite: Database.Database | undefined;
|
_client: ReturnType<typeof createClient> | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!globalForDb._sqlite) {
|
if (!globalForDb._client) {
|
||||||
globalForDb._sqlite = new Database(process.env.DATABASE_URL ?? "sqlite.db");
|
globalForDb._client = createClient({
|
||||||
globalForDb._sqlite.pragma("journal_mode = WAL");
|
url: process.env.DATABASE_URL ?? "file:sqlite.db",
|
||||||
globalForDb._sqlite.pragma("foreign_keys = ON");
|
});
|
||||||
globalForDb._sqlite.pragma("busy_timeout = 5000");
|
globalForDb._client.execute("PRAGMA journal_mode = WAL");
|
||||||
|
globalForDb._client.execute("PRAGMA foreign_keys = ON");
|
||||||
|
globalForDb._client.execute("PRAGMA busy_timeout = 5000");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!globalForDb._db) {
|
if (!globalForDb._db) {
|
||||||
globalForDb._db = drizzle(globalForDb._sqlite, { schema });
|
globalForDb._db = drizzle({ client: globalForDb._client, schema });
|
||||||
}
|
}
|
||||||
|
|
||||||
export const db = globalForDb._db;
|
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));
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLibraryTitleIds(): string[] {
|
async function getLibraryTitleIds(): Promise<string[]> {
|
||||||
return db
|
const rows = await db
|
||||||
.select({ titleId: userTitleStatus.titleId })
|
.select({ titleId: userTitleStatus.titleId })
|
||||||
.from(userTitleStatus)
|
.from(userTitleStatus)
|
||||||
.groupBy(userTitleStatus.titleId)
|
.groupBy(userTitleStatus.titleId)
|
||||||
.all()
|
.all();
|
||||||
.map((r) => r.titleId);
|
return rows.map((r) => r.titleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh titles where lastFetchedAt is stale
|
// Refresh titles where lastFetchedAt is stale
|
||||||
async function nightlyRefreshLibrary() {
|
async function nightlyRefreshLibrary() {
|
||||||
const libraryIds = getLibraryTitleIds();
|
const libraryIds = await getLibraryTitleIds();
|
||||||
const libraryStale = new Date(Date.now() - 7 * DAY);
|
const libraryStale = new Date(Date.now() - 7 * DAY);
|
||||||
const nonLibraryStale = new Date(Date.now() - 30 * DAY);
|
const nonLibraryStale = new Date(Date.now() - 30 * DAY);
|
||||||
|
|
||||||
// Library titles: 7 days
|
// Library titles: 7 days
|
||||||
for (const titleId of libraryIds) {
|
for (const titleId of libraryIds) {
|
||||||
const t = db
|
const t = await db
|
||||||
.select()
|
.select()
|
||||||
.from(titles)
|
.from(titles)
|
||||||
.where(
|
.where(
|
||||||
@@ -54,7 +54,7 @@ async function nightlyRefreshLibrary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Non-library titles: 30 days
|
// Non-library titles: 30 days
|
||||||
const nonLibrary = db
|
const nonLibrary = await db
|
||||||
.select()
|
.select()
|
||||||
.from(titles)
|
.from(titles)
|
||||||
.where(
|
.where(
|
||||||
@@ -76,12 +76,12 @@ async function nightlyRefreshLibrary() {
|
|||||||
|
|
||||||
// Refresh availability for library titles where stale
|
// Refresh availability for library titles where stale
|
||||||
async function refreshAvailabilityJob() {
|
async function refreshAvailabilityJob() {
|
||||||
const libraryIds = getLibraryTitleIds();
|
const libraryIds = await getLibraryTitleIds();
|
||||||
const stale = new Date(Date.now() - DAY);
|
const stale = new Date(Date.now() - DAY);
|
||||||
|
|
||||||
for (const titleId of libraryIds) {
|
for (const titleId of libraryIds) {
|
||||||
// Check if any offer is stale
|
// Check if any offer is stale
|
||||||
const offer = db
|
const offer = await db
|
||||||
.select()
|
.select()
|
||||||
.from(availabilityOffers)
|
.from(availabilityOffers)
|
||||||
.where(
|
.where(
|
||||||
@@ -93,7 +93,7 @@ async function refreshAvailabilityJob() {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
// Also handle titles with no offers yet
|
// Also handle titles with no offers yet
|
||||||
const anyOffer = db
|
const anyOffer = await db
|
||||||
.select()
|
.select()
|
||||||
.from(availabilityOffers)
|
.from(availabilityOffers)
|
||||||
.where(eq(availabilityOffers.titleId, titleId))
|
.where(eq(availabilityOffers.titleId, titleId))
|
||||||
@@ -108,7 +108,7 @@ async function refreshAvailabilityJob() {
|
|||||||
|
|
||||||
// Refresh recommendations for recently active titles
|
// Refresh recommendations for recently active titles
|
||||||
async function refreshRecommendationsJob() {
|
async function refreshRecommendationsJob() {
|
||||||
const libraryIds = getLibraryTitleIds();
|
const libraryIds = await getLibraryTitleIds();
|
||||||
|
|
||||||
for (const titleId of libraryIds) {
|
for (const titleId of libraryIds) {
|
||||||
await refreshRecommendations(titleId);
|
await refreshRecommendations(titleId);
|
||||||
@@ -121,7 +121,7 @@ async function refreshTvChildrenJob() {
|
|||||||
const returningStatuses = ["Returning Series", "In Production"];
|
const returningStatuses = ["Returning Series", "In Production"];
|
||||||
const stale = new Date(Date.now() - 7 * DAY);
|
const stale = new Date(Date.now() - 7 * DAY);
|
||||||
|
|
||||||
const tvShows = db
|
const tvShows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(titles)
|
.from(titles)
|
||||||
.where(
|
.where(
|
||||||
@@ -135,7 +135,7 @@ async function refreshTvChildrenJob() {
|
|||||||
|
|
||||||
for (const show of tvShows) {
|
for (const show of tvShows) {
|
||||||
// Check if seasons are stale
|
// Check if seasons are stale
|
||||||
const staleSeason = db
|
const staleSeason = await db
|
||||||
.select()
|
.select()
|
||||||
.from(seasons)
|
.from(seasons)
|
||||||
.where(
|
.where(
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { availabilityOffers, titles } from "@/lib/db/schema";
|
|||||||
import { getWatchProviders } from "@/lib/tmdb/client";
|
import { getWatchProviders } from "@/lib/tmdb/client";
|
||||||
|
|
||||||
export async function refreshAvailability(titleId: string) {
|
export async function refreshAvailability(titleId: string) {
|
||||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
const title = await db
|
||||||
|
.select()
|
||||||
|
.from(titles)
|
||||||
|
.where(eq(titles.id, titleId))
|
||||||
|
.get();
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
|
|
||||||
const data = await getWatchProviders(title.tmdbId, title.type);
|
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;
|
const offerTypes = ["flatrate", "rent", "buy", "free", "ads"] as const;
|
||||||
|
|
||||||
// Delete existing offers for this title+region
|
// Delete existing offers for this title+region
|
||||||
db.delete(availabilityOffers)
|
await db
|
||||||
|
.delete(availabilityOffers)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(availabilityOffers.titleId, titleId),
|
eq(availabilityOffers.titleId, titleId),
|
||||||
@@ -29,7 +34,8 @@ export async function refreshAvailability(titleId: string) {
|
|||||||
if (!providers) continue;
|
if (!providers) continue;
|
||||||
|
|
||||||
for (const p of providers) {
|
for (const p of providers) {
|
||||||
db.insert(availabilityOffers)
|
await db
|
||||||
|
.insert(availabilityOffers)
|
||||||
.values({
|
.values({
|
||||||
titleId,
|
titleId,
|
||||||
region: "US",
|
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
|
return db
|
||||||
.select()
|
.select()
|
||||||
.from(availabilityOffers)
|
.from(availabilityOffers)
|
||||||
|
|||||||
+54
-46
@@ -32,11 +32,11 @@ export interface ContinueWatchingItem {
|
|||||||
watchedEpisodes: number;
|
watchedEpisodes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getContinueWatchingFeed(
|
export async function getContinueWatchingFeed(
|
||||||
userId: string,
|
userId: string,
|
||||||
): ContinueWatchingItem[] {
|
): Promise<ContinueWatchingItem[]> {
|
||||||
// Get in-progress TV shows
|
// Get in-progress TV shows
|
||||||
const inProgress = db
|
const inProgress = await db
|
||||||
.select({
|
.select({
|
||||||
titleId: userTitleStatus.titleId,
|
titleId: userTitleStatus.titleId,
|
||||||
updatedAt: userTitleStatus.updatedAt,
|
updatedAt: userTitleStatus.updatedAt,
|
||||||
@@ -54,7 +54,7 @@ export function getContinueWatchingFeed(
|
|||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
for (const row of inProgress) {
|
for (const row of inProgress) {
|
||||||
const title = db
|
const title = await db
|
||||||
.select()
|
.select()
|
||||||
.from(titles)
|
.from(titles)
|
||||||
.where(and(eq(titles.id, row.titleId), eq(titles.type, "tv")))
|
.where(and(eq(titles.id, row.titleId), eq(titles.type, "tv")))
|
||||||
@@ -62,7 +62,7 @@ export function getContinueWatchingFeed(
|
|||||||
if (!title) continue;
|
if (!title) continue;
|
||||||
|
|
||||||
// Get all seasons for this title, ordered
|
// Get all seasons for this title, ordered
|
||||||
const titleSeasons = db
|
const titleSeasons = await db
|
||||||
.select()
|
.select()
|
||||||
.from(seasons)
|
.from(seasons)
|
||||||
.where(eq(seasons.titleId, title.id))
|
.where(eq(seasons.titleId, title.id))
|
||||||
@@ -77,7 +77,7 @@ export function getContinueWatchingFeed(
|
|||||||
|
|
||||||
// Get most recent watch for this show
|
// Get most recent watch for this show
|
||||||
for (const s of titleSeasons) {
|
for (const s of titleSeasons) {
|
||||||
const eps = db
|
const eps = await db
|
||||||
.select()
|
.select()
|
||||||
.from(episodes)
|
.from(episodes)
|
||||||
.where(eq(episodes.seasonId, s.id))
|
.where(eq(episodes.seasonId, s.id))
|
||||||
@@ -87,7 +87,7 @@ export function getContinueWatchingFeed(
|
|||||||
totalEpisodes += eps.length;
|
totalEpisodes += eps.length;
|
||||||
|
|
||||||
for (const ep of eps) {
|
for (const ep of eps) {
|
||||||
const watch = db
|
const watch = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userEpisodeWatches)
|
.from(userEpisodeWatches)
|
||||||
.where(
|
.where(
|
||||||
@@ -146,10 +146,10 @@ export function getContinueWatchingFeed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/correctness/noUnusedFunctionParameters: days reserved for future date filtering
|
// 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
|
// Get titles the user has in any status that have availability offers
|
||||||
// and recent release/air dates
|
// and recent release/air dates
|
||||||
const results = db
|
const results = await db
|
||||||
.select({
|
.select({
|
||||||
titleId: titles.id,
|
titleId: titles.id,
|
||||||
title: titles.title,
|
title: titles.title,
|
||||||
@@ -177,46 +177,52 @@ export function getNewAvailableFeed(userId: string, days = 14) {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRecommendationsFeed(userId: string) {
|
export async function getRecommendationsFeed(userId: string) {
|
||||||
// Get recommendations from user's highly-rated or completed titles
|
// Get recommendations from user's highly-rated or completed titles
|
||||||
const userCompletedOrRated = db
|
const userCompletedOrRated = (
|
||||||
.select({ titleId: userTitleStatus.titleId })
|
await db
|
||||||
.from(userTitleStatus)
|
.select({ titleId: userTitleStatus.titleId })
|
||||||
.where(
|
.from(userTitleStatus)
|
||||||
and(
|
.where(
|
||||||
eq(userTitleStatus.userId, userId),
|
and(
|
||||||
eq(userTitleStatus.status, "completed"),
|
eq(userTitleStatus.userId, userId),
|
||||||
),
|
eq(userTitleStatus.status, "completed"),
|
||||||
)
|
),
|
||||||
.all()
|
)
|
||||||
.map((r) => r.titleId);
|
.all()
|
||||||
|
).map((r) => r.titleId);
|
||||||
|
|
||||||
const ratedIds = db
|
const ratedIds = (
|
||||||
.select({ titleId: userRatings.titleId })
|
await db
|
||||||
.from(userRatings)
|
.select({ titleId: userRatings.titleId })
|
||||||
.where(
|
.from(userRatings)
|
||||||
and(eq(userRatings.userId, userId), sql`${userRatings.ratingStars} >= 4`),
|
.where(
|
||||||
)
|
and(
|
||||||
.all()
|
eq(userRatings.userId, userId),
|
||||||
.map((r) => r.titleId);
|
sql`${userRatings.ratingStars} >= 4`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
).map((r) => r.titleId);
|
||||||
|
|
||||||
const sourceIds = [...new Set([...userCompletedOrRated, ...ratedIds])];
|
const sourceIds = [...new Set([...userCompletedOrRated, ...ratedIds])];
|
||||||
if (sourceIds.length === 0) return [];
|
if (sourceIds.length === 0) return [];
|
||||||
|
|
||||||
// Get all tracked title IDs to exclude
|
// Get all tracked title IDs to exclude
|
||||||
const trackedIds = new Set(
|
const trackedIds = new Set(
|
||||||
db
|
(
|
||||||
.select({ titleId: userTitleStatus.titleId })
|
await db
|
||||||
.from(userTitleStatus)
|
.select({ titleId: userTitleStatus.titleId })
|
||||||
.where(eq(userTitleStatus.userId, userId))
|
.from(userTitleStatus)
|
||||||
.all()
|
.where(eq(userTitleStatus.userId, userId))
|
||||||
.map((r) => r.titleId),
|
.all()
|
||||||
|
).map((r) => r.titleId),
|
||||||
);
|
);
|
||||||
|
|
||||||
const recs: Map<string, { titleId: string; score: number }> = new Map();
|
const recs: Map<string, { titleId: string; score: number }> = new Map();
|
||||||
|
|
||||||
for (const sourceId of sourceIds) {
|
for (const sourceId of sourceIds) {
|
||||||
const recRows = db
|
const recRows = await db
|
||||||
.select({
|
.select({
|
||||||
recommendedTitleId: titleRecommendations.recommendedTitleId,
|
recommendedTitleId: titleRecommendations.recommendedTitleId,
|
||||||
rank: titleRecommendations.rank,
|
rank: titleRecommendations.rank,
|
||||||
@@ -244,14 +250,16 @@ export function getRecommendationsFeed(userId: string) {
|
|||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.slice(0, 20);
|
.slice(0, 20);
|
||||||
|
|
||||||
return sorted
|
return (
|
||||||
.map((r) => {
|
await Promise.all(
|
||||||
const title = db
|
sorted.map(async (r) => {
|
||||||
.select()
|
const title = await db
|
||||||
.from(titles)
|
.select()
|
||||||
.where(eq(titles.id, r.titleId))
|
.from(titles)
|
||||||
.get();
|
.where(eq(titles.id, r.titleId))
|
||||||
return title;
|
.get();
|
||||||
})
|
return title;
|
||||||
.filter(Boolean);
|
}),
|
||||||
|
)
|
||||||
|
).filter(Boolean);
|
||||||
}
|
}
|
||||||
+39
-23
@@ -16,7 +16,7 @@ import {
|
|||||||
import { refreshAvailability } from "./availability";
|
import { refreshAvailability } from "./availability";
|
||||||
|
|
||||||
export async function importTitle(tmdbId: number, type: "movie" | "tv") {
|
export async function importTitle(tmdbId: number, type: "movie" | "tv") {
|
||||||
const existing = db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(titles)
|
.from(titles)
|
||||||
.where(eq(titles.tmdbId, tmdbId))
|
.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
|
// They may be missing if a prior fetch failed or the title was created
|
||||||
// as a shell by the recommendations system (lastFetchedAt: null).
|
// as a shell by the recommendations system (lastFetchedAt: null).
|
||||||
if (existing.type === "tv") {
|
if (existing.type === "tv") {
|
||||||
const seasonCount = db
|
const seasonCount = (
|
||||||
.select()
|
await db
|
||||||
.from(seasons)
|
.select()
|
||||||
.where(eq(seasons.titleId, existing.id))
|
.from(seasons)
|
||||||
.all().length;
|
.where(eq(seasons.titleId, existing.id))
|
||||||
|
.all()
|
||||||
|
).length;
|
||||||
if (seasonCount === 0) {
|
if (seasonCount === 0) {
|
||||||
const show = await getTvDetails(tmdbId);
|
const show = await getTvDetails(tmdbId);
|
||||||
if (!existing.lastFetchedAt) {
|
if (!existing.lastFetchedAt) {
|
||||||
db.update(titles)
|
await db
|
||||||
|
.update(titles)
|
||||||
.set({
|
.set({
|
||||||
overview: show.overview,
|
overview: show.overview,
|
||||||
posterPath: show.poster_path,
|
posterPath: show.poster_path,
|
||||||
@@ -58,7 +61,7 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
|
|||||||
|
|
||||||
if (type === "movie") {
|
if (type === "movie") {
|
||||||
const movie = await getMovieDetails(tmdbId);
|
const movie = await getMovieDetails(tmdbId);
|
||||||
const row = db
|
const row = await db
|
||||||
.insert(titles)
|
.insert(titles)
|
||||||
.values({
|
.values({
|
||||||
tmdbId: movie.id,
|
tmdbId: movie.id,
|
||||||
@@ -84,7 +87,7 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const show = await getTvDetails(tmdbId);
|
const show = await getTvDetails(tmdbId);
|
||||||
const row = db
|
const row = await db
|
||||||
.insert(titles)
|
.insert(titles)
|
||||||
.values({
|
.values({
|
||||||
tmdbId: show.id,
|
tmdbId: show.id,
|
||||||
@@ -112,14 +115,19 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshTitle(titleId: string) {
|
export async function refreshTitle(titleId: string) {
|
||||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
const title = await db
|
||||||
|
.select()
|
||||||
|
.from(titles)
|
||||||
|
.where(eq(titles.id, titleId))
|
||||||
|
.get();
|
||||||
if (!title) return null;
|
if (!title) return null;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
if (title.type === "movie") {
|
if (title.type === "movie") {
|
||||||
const movie = await getMovieDetails(title.tmdbId);
|
const movie = await getMovieDetails(title.tmdbId);
|
||||||
db.update(titles)
|
await db
|
||||||
|
.update(titles)
|
||||||
.set({
|
.set({
|
||||||
title: movie.title,
|
title: movie.title,
|
||||||
originalTitle: movie.original_title,
|
originalTitle: movie.original_title,
|
||||||
@@ -137,7 +145,8 @@ export async function refreshTitle(titleId: string) {
|
|||||||
.run();
|
.run();
|
||||||
} else {
|
} else {
|
||||||
const show = await getTvDetails(title.tmdbId);
|
const show = await getTvDetails(title.tmdbId);
|
||||||
db.update(titles)
|
await db
|
||||||
|
.update(titles)
|
||||||
.set({
|
.set({
|
||||||
title: show.name,
|
title: show.name,
|
||||||
originalTitle: show.original_name,
|
originalTitle: show.original_name,
|
||||||
@@ -173,7 +182,7 @@ export async function refreshTvChildren(
|
|||||||
try {
|
try {
|
||||||
const seasonData = await getTvSeasonDetails(tmdbId, sn);
|
const seasonData = await getTvSeasonDetails(tmdbId, sn);
|
||||||
|
|
||||||
const seasonRow = db
|
const seasonRow = await db
|
||||||
.insert(seasons)
|
.insert(seasons)
|
||||||
.values({
|
.values({
|
||||||
titleId,
|
titleId,
|
||||||
@@ -198,7 +207,8 @@ export async function refreshTvChildren(
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
for (const ep of seasonData.episodes) {
|
for (const ep of seasonData.episodes) {
|
||||||
db.insert(episodes)
|
await db
|
||||||
|
.insert(episodes)
|
||||||
.values({
|
.values({
|
||||||
seasonId: seasonRow.id,
|
seasonId: seasonRow.id,
|
||||||
episodeNumber: ep.episode_number,
|
episodeNumber: ep.episode_number,
|
||||||
@@ -229,7 +239,11 @@ export async function refreshTvChildren(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshRecommendations(titleId: string) {
|
export async function refreshRecommendations(titleId: string) {
|
||||||
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
const title = await db
|
||||||
|
.select()
|
||||||
|
.from(titles)
|
||||||
|
.where(eq(titles.id, titleId))
|
||||||
|
.get();
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -247,7 +261,7 @@ export async function refreshRecommendations(titleId: string) {
|
|||||||
if (type !== "movie" && type !== "tv") continue;
|
if (type !== "movie" && type !== "tv") continue;
|
||||||
|
|
||||||
// Minimal upsert of the recommended title
|
// Minimal upsert of the recommended title
|
||||||
const existing = db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(titles)
|
.from(titles)
|
||||||
.where(eq(titles.tmdbId, r.id))
|
.where(eq(titles.tmdbId, r.id))
|
||||||
@@ -256,7 +270,7 @@ export async function refreshRecommendations(titleId: string) {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
recTitleId = existing.id;
|
recTitleId = existing.id;
|
||||||
} else {
|
} else {
|
||||||
const row = db
|
const row = await db
|
||||||
.insert(titles)
|
.insert(titles)
|
||||||
.values({
|
.values({
|
||||||
tmdbId: r.id,
|
tmdbId: r.id,
|
||||||
@@ -277,7 +291,7 @@ export async function refreshRecommendations(titleId: string) {
|
|||||||
.returning()
|
.returning()
|
||||||
.get();
|
.get();
|
||||||
if (!row) {
|
if (!row) {
|
||||||
const found = db
|
const found = await db
|
||||||
.select()
|
.select()
|
||||||
.from(titles)
|
.from(titles)
|
||||||
.where(eq(titles.tmdbId, r.id))
|
.where(eq(titles.tmdbId, r.id))
|
||||||
@@ -289,7 +303,8 @@ export async function refreshRecommendations(titleId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db.insert(titleRecommendations)
|
await db
|
||||||
|
.insert(titleRecommendations)
|
||||||
.values({
|
.values({
|
||||||
titleId,
|
titleId,
|
||||||
recommendedTitleId: recTitleId,
|
recommendedTitleId: recTitleId,
|
||||||
@@ -314,7 +329,7 @@ export async function refreshRecommendations(titleId: string) {
|
|||||||
const type = r.media_type ?? title.type;
|
const type = r.media_type ?? title.type;
|
||||||
if (type !== "movie" && type !== "tv") continue;
|
if (type !== "movie" && type !== "tv") continue;
|
||||||
|
|
||||||
const existing = db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(titles)
|
.from(titles)
|
||||||
.where(eq(titles.tmdbId, r.id))
|
.where(eq(titles.tmdbId, r.id))
|
||||||
@@ -323,7 +338,7 @@ export async function refreshRecommendations(titleId: string) {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
recTitleId = existing.id;
|
recTitleId = existing.id;
|
||||||
} else {
|
} else {
|
||||||
const row = db
|
const row = await db
|
||||||
.insert(titles)
|
.insert(titles)
|
||||||
.values({
|
.values({
|
||||||
tmdbId: r.id,
|
tmdbId: r.id,
|
||||||
@@ -344,7 +359,7 @@ export async function refreshRecommendations(titleId: string) {
|
|||||||
.returning()
|
.returning()
|
||||||
.get();
|
.get();
|
||||||
if (!row) {
|
if (!row) {
|
||||||
const found = db
|
const found = await db
|
||||||
.select()
|
.select()
|
||||||
.from(titles)
|
.from(titles)
|
||||||
.where(eq(titles.tmdbId, r.id))
|
.where(eq(titles.tmdbId, r.id))
|
||||||
@@ -356,7 +371,8 @@ export async function refreshRecommendations(titleId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db.insert(titleRecommendations)
|
await db
|
||||||
|
.insert(titleRecommendations)
|
||||||
.values({
|
.values({
|
||||||
titleId,
|
titleId,
|
||||||
recommendedTitleId: recTitleId,
|
recommendedTitleId: recTitleId,
|
||||||
|
|||||||
+70
-49
@@ -10,13 +10,14 @@ import {
|
|||||||
userTitleStatus,
|
userTitleStatus,
|
||||||
} from "@/lib/db/schema";
|
} from "@/lib/db/schema";
|
||||||
|
|
||||||
export function setTitleStatus(
|
export async function setTitleStatus(
|
||||||
userId: string,
|
userId: string,
|
||||||
titleId: string,
|
titleId: string,
|
||||||
status: "watchlist" | "in_progress" | "completed",
|
status: "watchlist" | "in_progress" | "completed",
|
||||||
) {
|
) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
db.insert(userTitleStatus)
|
await db
|
||||||
|
.insert(userTitleStatus)
|
||||||
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
|
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [userTitleStatus.userId, userTitleStatus.titleId],
|
target: [userTitleStatus.userId, userTitleStatus.titleId],
|
||||||
@@ -25,12 +26,13 @@ export function setTitleStatus(
|
|||||||
.run();
|
.run();
|
||||||
|
|
||||||
if (status === "completed") {
|
if (status === "completed") {
|
||||||
markAllEpisodesWatched(userId, titleId);
|
await markAllEpisodesWatched(userId, titleId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeTitleStatus(userId: string, titleId: string) {
|
export async function removeTitleStatus(userId: string, titleId: string) {
|
||||||
db.delete(userTitleStatus)
|
await db
|
||||||
|
.delete(userTitleStatus)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(userTitleStatus.userId, userId),
|
eq(userTitleStatus.userId, userId),
|
||||||
@@ -40,14 +42,15 @@ export function removeTitleStatus(userId: string, titleId: string) {
|
|||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logMovieWatch(userId: string, titleId: string) {
|
export async function logMovieWatch(userId: string, titleId: string) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
db.insert(userMovieWatches)
|
await db
|
||||||
|
.insert(userMovieWatches)
|
||||||
.values({ userId, titleId, watchedAt: now, source: "manual" })
|
.values({ userId, titleId, watchedAt: now, source: "manual" })
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
// Auto-set status to completed
|
// Auto-set status to completed
|
||||||
const existing = db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userTitleStatus)
|
.from(userTitleStatus)
|
||||||
.where(
|
.where(
|
||||||
@@ -59,22 +62,27 @@ export function logMovieWatch(userId: string, titleId: string) {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
setTitleStatus(userId, titleId, "completed");
|
await setTitleStatus(userId, titleId, "completed");
|
||||||
} else if (existing.status !== "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();
|
const now = new Date();
|
||||||
db.insert(userEpisodeWatches)
|
await db
|
||||||
|
.insert(userEpisodeWatches)
|
||||||
.values({ userId, episodeId, watchedAt: now, source: "manual" })
|
.values({ userId, episodeId, watchedAt: now, source: "manual" })
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
// Find the title for this episode
|
// 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;
|
if (!ep) return;
|
||||||
const season = db
|
const season = await db
|
||||||
.select()
|
.select()
|
||||||
.from(seasons)
|
.from(seasons)
|
||||||
.where(eq(seasons.id, ep.seasonId))
|
.where(eq(seasons.id, ep.seasonId))
|
||||||
@@ -83,7 +91,7 @@ export function logEpisodeWatch(userId: string, episodeId: string) {
|
|||||||
const titleId = season.titleId;
|
const titleId = season.titleId;
|
||||||
|
|
||||||
// Auto-set status to in_progress if not set
|
// Auto-set status to in_progress if not set
|
||||||
const existing = db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userTitleStatus)
|
.from(userTitleStatus)
|
||||||
.where(
|
.where(
|
||||||
@@ -95,33 +103,37 @@ export function logEpisodeWatch(userId: string, episodeId: string) {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
setTitleStatus(userId, titleId, "in_progress");
|
await setTitleStatus(userId, titleId, "in_progress");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if all episodes are watched -> auto-complete
|
// Check if all episodes are watched -> auto-complete
|
||||||
checkAllEpisodesWatched(userId, titleId);
|
await checkAllEpisodesWatched(userId, titleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function markAllEpisodesWatched(userId: string, titleId: string) {
|
async function markAllEpisodesWatched(userId: string, 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 || title.type !== "tv") return;
|
if (!title || title.type !== "tv") return;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const allSeasons = db
|
const allSeasons = await db
|
||||||
.select()
|
.select()
|
||||||
.from(seasons)
|
.from(seasons)
|
||||||
.where(eq(seasons.titleId, titleId))
|
.where(eq(seasons.titleId, titleId))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
for (const s of allSeasons) {
|
for (const s of allSeasons) {
|
||||||
const eps = db
|
const eps = await db
|
||||||
.select()
|
.select()
|
||||||
.from(episodes)
|
.from(episodes)
|
||||||
.where(eq(episodes.seasonId, s.id))
|
.where(eq(episodes.seasonId, s.id))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
for (const ep of eps) {
|
for (const ep of eps) {
|
||||||
const existing = db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userEpisodeWatches)
|
.from(userEpisodeWatches)
|
||||||
.where(
|
.where(
|
||||||
@@ -132,7 +144,8 @@ function markAllEpisodesWatched(userId: string, titleId: string) {
|
|||||||
)
|
)
|
||||||
.get();
|
.get();
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
db.insert(userEpisodeWatches)
|
await db
|
||||||
|
.insert(userEpisodeWatches)
|
||||||
.values({
|
.values({
|
||||||
userId,
|
userId,
|
||||||
episodeId: ep.id,
|
episodeId: ep.id,
|
||||||
@@ -145,8 +158,8 @@ function markAllEpisodesWatched(userId: string, titleId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkAllEpisodesWatched(userId: string, titleId: string) {
|
async function checkAllEpisodesWatched(userId: string, titleId: string) {
|
||||||
const allSeasons = db
|
const allSeasons = await db
|
||||||
.select()
|
.select()
|
||||||
.from(seasons)
|
.from(seasons)
|
||||||
.where(eq(seasons.titleId, titleId))
|
.where(eq(seasons.titleId, titleId))
|
||||||
@@ -156,7 +169,7 @@ function checkAllEpisodesWatched(userId: string, titleId: string) {
|
|||||||
let watchedEpisodes = 0;
|
let watchedEpisodes = 0;
|
||||||
|
|
||||||
for (const s of allSeasons) {
|
for (const s of allSeasons) {
|
||||||
const eps = db
|
const eps = await db
|
||||||
.select()
|
.select()
|
||||||
.from(episodes)
|
.from(episodes)
|
||||||
.where(eq(episodes.seasonId, s.id))
|
.where(eq(episodes.seasonId, s.id))
|
||||||
@@ -164,7 +177,7 @@ function checkAllEpisodesWatched(userId: string, titleId: string) {
|
|||||||
totalEpisodes += eps.length;
|
totalEpisodes += eps.length;
|
||||||
|
|
||||||
for (const ep of eps) {
|
for (const ep of eps) {
|
||||||
const watch = db
|
const watch = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userEpisodeWatches)
|
.from(userEpisodeWatches)
|
||||||
.where(
|
.where(
|
||||||
@@ -179,12 +192,13 @@ function checkAllEpisodesWatched(userId: string, titleId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalEpisodes > 0 && watchedEpisodes >= totalEpisodes) {
|
if (totalEpisodes > 0 && watchedEpisodes >= totalEpisodes) {
|
||||||
setTitleStatus(userId, titleId, "completed");
|
await setTitleStatus(userId, titleId, "completed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unwatchEpisode(userId: string, episodeId: string) {
|
export async function unwatchEpisode(userId: string, episodeId: string) {
|
||||||
db.delete(userEpisodeWatches)
|
await db
|
||||||
|
.delete(userEpisodeWatches)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(userEpisodeWatches.userId, userId),
|
eq(userEpisodeWatches.userId, userId),
|
||||||
@@ -194,16 +208,20 @@ export function unwatchEpisode(userId: string, episodeId: string) {
|
|||||||
.run();
|
.run();
|
||||||
|
|
||||||
// Find parent title and downgrade from completed to in_progress
|
// 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;
|
if (!ep) return;
|
||||||
const season = db
|
const season = await db
|
||||||
.select()
|
.select()
|
||||||
.from(seasons)
|
.from(seasons)
|
||||||
.where(eq(seasons.id, ep.seasonId))
|
.where(eq(seasons.id, ep.seasonId))
|
||||||
.get();
|
.get();
|
||||||
if (!season) return;
|
if (!season) return;
|
||||||
|
|
||||||
const existing = db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userTitleStatus)
|
.from(userTitleStatus)
|
||||||
.where(
|
.where(
|
||||||
@@ -215,12 +233,12 @@ export function unwatchEpisode(userId: string, episodeId: string) {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (existing?.status === "completed") {
|
if (existing?.status === "completed") {
|
||||||
setTitleStatus(userId, season.titleId, "in_progress");
|
await setTitleStatus(userId, season.titleId, "in_progress");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unwatchSeason(userId: string, seasonId: string) {
|
export async function unwatchSeason(userId: string, seasonId: string) {
|
||||||
const seasonEps = db
|
const seasonEps = await db
|
||||||
.select()
|
.select()
|
||||||
.from(episodes)
|
.from(episodes)
|
||||||
.where(eq(episodes.seasonId, seasonId))
|
.where(eq(episodes.seasonId, seasonId))
|
||||||
@@ -228,7 +246,8 @@ export function unwatchSeason(userId: string, seasonId: string) {
|
|||||||
|
|
||||||
const epIds = seasonEps.map((ep) => ep.id);
|
const epIds = seasonEps.map((ep) => ep.id);
|
||||||
if (epIds.length > 0) {
|
if (epIds.length > 0) {
|
||||||
db.delete(userEpisodeWatches)
|
await db
|
||||||
|
.delete(userEpisodeWatches)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(userEpisodeWatches.userId, userId),
|
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
|
// Find parent title and downgrade from completed to in_progress
|
||||||
const season = db
|
const season = await db
|
||||||
.select()
|
.select()
|
||||||
.from(seasons)
|
.from(seasons)
|
||||||
.where(eq(seasons.id, seasonId))
|
.where(eq(seasons.id, seasonId))
|
||||||
.get();
|
.get();
|
||||||
if (!season) return;
|
if (!season) return;
|
||||||
|
|
||||||
const existing = db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userTitleStatus)
|
.from(userTitleStatus)
|
||||||
.where(
|
.where(
|
||||||
@@ -258,25 +277,27 @@ export function unwatchSeason(userId: string, seasonId: string) {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (existing?.status === "completed") {
|
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,
|
userId: string,
|
||||||
titleId: string,
|
titleId: string,
|
||||||
ratingStars: number,
|
ratingStars: number,
|
||||||
) {
|
) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (ratingStars === 0) {
|
if (ratingStars === 0) {
|
||||||
db.delete(userRatings)
|
await db
|
||||||
|
.delete(userRatings)
|
||||||
.where(
|
.where(
|
||||||
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
|
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
db.insert(userRatings)
|
await db
|
||||||
|
.insert(userRatings)
|
||||||
.values({ userId, titleId, ratingStars, ratedAt: now })
|
.values({ userId, titleId, ratingStars, ratedAt: now })
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [userRatings.userId, userRatings.titleId],
|
target: [userRatings.userId, userRatings.titleId],
|
||||||
@@ -285,8 +306,8 @@ export function rateTitleStars(
|
|||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserTitleInfo(userId: string, titleId: string) {
|
export async function getUserTitleInfo(userId: string, titleId: string) {
|
||||||
const status = db
|
const status = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userTitleStatus)
|
.from(userTitleStatus)
|
||||||
.where(
|
.where(
|
||||||
@@ -297,7 +318,7 @@ export function getUserTitleInfo(userId: string, titleId: string) {
|
|||||||
)
|
)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
const rating = db
|
const rating = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userRatings)
|
.from(userRatings)
|
||||||
.where(
|
.where(
|
||||||
@@ -306,7 +327,7 @@ export function getUserTitleInfo(userId: string, titleId: string) {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
// Get watched episode IDs for this title
|
// Get watched episode IDs for this title
|
||||||
const titleSeasons = db
|
const titleSeasons = await db
|
||||||
.select()
|
.select()
|
||||||
.from(seasons)
|
.from(seasons)
|
||||||
.where(eq(seasons.titleId, titleId))
|
.where(eq(seasons.titleId, titleId))
|
||||||
@@ -314,13 +335,13 @@ export function getUserTitleInfo(userId: string, titleId: string) {
|
|||||||
|
|
||||||
const watchedEpisodeIds: string[] = [];
|
const watchedEpisodeIds: string[] = [];
|
||||||
for (const s of titleSeasons) {
|
for (const s of titleSeasons) {
|
||||||
const eps = db
|
const eps = await db
|
||||||
.select()
|
.select()
|
||||||
.from(episodes)
|
.from(episodes)
|
||||||
.where(eq(episodes.seasonId, s.id))
|
.where(eq(episodes.seasonId, s.id))
|
||||||
.all();
|
.all();
|
||||||
for (const ep of eps) {
|
for (const ep of eps) {
|
||||||
const watch = db
|
const watch = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userEpisodeWatches)
|
.from(userEpisodeWatches)
|
||||||
.where(
|
.where(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
|||||||
+9
-8
@@ -8,6 +8,7 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "biome check",
|
"lint": "biome check",
|
||||||
"format": "biome format --write",
|
"format": "biome format --write",
|
||||||
|
"check-types": "tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
@@ -15,14 +16,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
|
"@libsql/client": "^0.17.0",
|
||||||
"@tabler/icons-react": "^3.37.1",
|
"@tabler/icons-react": "^3.37.1",
|
||||||
"better-auth": "^1.4.19",
|
"better-auth": "^1.5.0",
|
||||||
"better-sqlite3": "^12.6.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "1.0.0-beta.15-859cf75",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"motion": "^12.34.3",
|
"motion": "^12.34.3",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
@@ -30,24 +31,24 @@
|
|||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-hotkeys-hook": "^5.2.4",
|
"react-hotkeys-hook": "^5.2.4",
|
||||||
"react-resizable-panels": "^4.6.5",
|
"react-resizable-panels": "^4.7.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"shadcn": "^3.8.5",
|
"shadcn": "^3.8.5",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"vaul": "^1.1.2"
|
"vaul": "^1.1.2",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.4",
|
"@biomejs/biome": "2.4.4",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/node": "^25.3.3",
|
||||||
"@types/node": "^25.3.2",
|
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "1.0.0-beta.15-859cf75",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1681
-382
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user