Implement full Couch Potato movie & TV tracking app

Add all 10 milestones: Drizzle ORM + SQLite database with WAL mode,
Better Auth email/password authentication, TMDB API integration for
search and metadata import, TV season/episode caching, user tracking
(watchlist/status/watches/ratings with auto-transitions), discovery
feeds (continue watching, library, recommendations), US streaming
availability via TMDB providers, background job scheduler with
instrumentation hook, and dark cinema-themed frontend with DM Serif
Display + DM Sans typography and amber accent design system.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 14:42:13 -05:00
co-authored by Claude Opus 4.6
parent 1b0ca5431a
commit b02ff1cdc1
65 changed files with 4994 additions and 382 deletions
+25
View File
@@ -0,0 +1,25 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { badRequest } from "@/lib/api/errors";
import { registerJobs } from "@/lib/jobs/registry";
import { scheduler } from "@/lib/jobs/scheduler";
// Ensure jobs are registered for manual triggering
registerJobs();
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const { name } = await params;
const jobNames = scheduler.getJobNames();
if (!jobNames.includes(name)) {
return badRequest(
`Unknown job: ${name}. Available: ${jobNames.join(", ")}`,
);
}
await scheduler.runNow(name);
return NextResponse.json({ ok: true, job: name });
}
+4
View File
@@ -0,0 +1,4 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth/server";
export const { GET, POST } = toNextJsHandler(auth);
+21
View File
@@ -0,0 +1,21 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { AuthError, requireAuth } from "@/lib/api/auth-guard";
import { unauthorized } from "@/lib/api/errors";
import { logEpisodeWatch } from "@/lib/services/tracking";
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
let userId: string;
try {
userId = await requireAuth();
} catch (e) {
if (e instanceof AuthError) return unauthorized();
throw e;
}
const { id } = await params;
logEpisodeWatch(userId, id);
return NextResponse.json({ ok: true });
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { AuthError, requireAuth } from "@/lib/api/auth-guard";
import { unauthorized } from "@/lib/api/errors";
import { getContinueWatchingFeed } from "@/lib/services/discovery";
export async function GET() {
let userId: string;
try {
userId = await requireAuth();
} catch (e) {
if (e instanceof AuthError) return unauthorized();
throw e;
}
const feed = getContinueWatchingFeed(userId);
return NextResponse.json(feed);
}
+18
View File
@@ -0,0 +1,18 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { AuthError, requireAuth } from "@/lib/api/auth-guard";
import { unauthorized } from "@/lib/api/errors";
import { getNewAvailableFeed } from "@/lib/services/discovery";
export async function GET(req: NextRequest) {
let userId: string;
try {
userId = await requireAuth();
} catch (e) {
if (e instanceof AuthError) return unauthorized();
throw e;
}
const days = Number(req.nextUrl.searchParams.get("days") ?? 14);
const feed = getNewAvailableFeed(userId, days);
return NextResponse.json(feed);
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { AuthError, requireAuth } from "@/lib/api/auth-guard";
import { unauthorized } from "@/lib/api/errors";
import { getRecommendationsFeed } from "@/lib/services/discovery";
export async function GET() {
let userId: string;
try {
userId = await requireAuth();
} catch (e) {
if (e instanceof AuthError) return unauthorized();
throw e;
}
const feed = getRecommendationsFeed(userId);
return NextResponse.json(feed);
}
+21
View File
@@ -0,0 +1,21 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { AuthError, requireAuth } from "@/lib/api/auth-guard";
import { unauthorized } from "@/lib/api/errors";
import { logMovieWatch } from "@/lib/services/tracking";
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
let userId: string;
try {
userId = await requireAuth();
} catch (e) {
if (e instanceof AuthError) return unauthorized();
throw e;
}
const { id } = await params;
logMovieWatch(userId, id);
return NextResponse.json({ ok: true });
}
+38
View File
@@ -0,0 +1,38 @@
import { type NextRequest, NextResponse } from "next/server";
import { badRequest } from "@/lib/api/errors";
import { searchMovies, searchMulti, searchTv } from "@/lib/tmdb/client";
import type { TmdbSearchResponse } from "@/lib/tmdb/types";
export async function GET(req: NextRequest) {
const query = req.nextUrl.searchParams.get("query");
const type = req.nextUrl.searchParams.get("type");
if (!query) return badRequest("query parameter is required");
let results: TmdbSearchResponse;
if (type === "movie") {
results = await searchMovies(query);
} else if (type === "tv") {
results = await searchTv(query);
} else {
results = await searchMulti(query);
}
// Filter out person results from multi search
const filtered = results.results.filter(
(r) => r.media_type !== "person" || type,
);
return NextResponse.json({
results: filtered.map((r) => ({
tmdbId: r.id,
type: r.media_type ?? type,
title: r.title ?? r.name,
overview: r.overview,
releaseDate: r.release_date ?? r.first_air_date,
posterPath: r.poster_path,
popularity: r.popularity,
voteAverage: r.vote_average,
})),
});
}
+28
View File
@@ -0,0 +1,28 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { AuthError, requireAuth } from "@/lib/api/auth-guard";
import { badRequest, unauthorized } from "@/lib/api/errors";
import { rateTitleStars } from "@/lib/services/tracking";
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
let userId: string;
try {
userId = await requireAuth();
} catch (e) {
if (e instanceof AuthError) return unauthorized();
throw e;
}
const { id } = await params;
const body = await req.json();
const { ratingStars } = body;
if (typeof ratingStars !== "number" || ratingStars < 0 || ratingStars > 5) {
return badRequest("ratingStars must be 0-5");
}
rateTitleStars(userId, id, ratingStars);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,41 @@
import { eq } from "drizzle-orm";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { notFound } from "@/lib/api/errors";
import { db } from "@/lib/db/client";
import { titleRecommendations, titles } from "@/lib/db/schema";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const title = db.select().from(titles).where(eq(titles.id, id)).get();
if (!title) return notFound("Title not found");
const recs = db
.select({
recommendedTitleId: titleRecommendations.recommendedTitleId,
source: titleRecommendations.source,
rank: titleRecommendations.rank,
})
.from(titleRecommendations)
.where(eq(titleRecommendations.titleId, id))
.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);
return NextResponse.json(results);
}
+63
View File
@@ -0,0 +1,63 @@
import { eq } from "drizzle-orm";
import { type NextRequest, NextResponse } from "next/server";
import { notFound } from "@/lib/api/errors";
import { db } from "@/lib/db/client";
import { availabilityOffers, episodes, seasons, titles } from "@/lib/db/schema";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const title = db.select().from(titles).where(eq(titles.id, id)).get();
if (!title) return notFound("Title not found");
let titleSeasons: Array<{
id: string;
seasonNumber: number;
name: string | null;
overview: string | null;
posterPath: string | null;
airDate: string | null;
episodes: Array<{
id: string;
episodeNumber: number;
name: string | null;
overview: string | null;
stillPath: string | null;
airDate: string | null;
runtimeMinutes: number | null;
}>;
}> = [];
if (title.type === "tv") {
const seasonRows = 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(),
}));
}
const availability = db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, title.id))
.all();
return NextResponse.json({
...title,
seasons: titleSeasons,
availability,
});
}
+49
View File
@@ -0,0 +1,49 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { AuthError, requireAuth } from "@/lib/api/auth-guard";
import { unauthorized } from "@/lib/api/errors";
import {
getUserTitleInfo,
removeTitleStatus,
setTitleStatus,
} from "@/lib/services/tracking";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
let userId: string;
try {
userId = await requireAuth();
} catch (e) {
if (e instanceof AuthError) return unauthorized();
throw e;
}
const { id } = await params;
const info = getUserTitleInfo(userId, id);
return NextResponse.json(info);
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
let userId: string;
try {
userId = await requireAuth();
} catch (e) {
if (e instanceof AuthError) return unauthorized();
throw e;
}
const { id } = await params;
const body = await req.json();
const { status } = body;
if (status === null || status === undefined) {
removeTitleStatus(userId, id);
} else {
setTitleStatus(userId, id, status);
}
return NextResponse.json({ ok: true });
}
+24
View File
@@ -0,0 +1,24 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { AuthError, requireAuth } from "@/lib/api/auth-guard";
import { badRequest, unauthorized } from "@/lib/api/errors";
import { importTitle } from "@/lib/services/metadata";
export async function POST(req: NextRequest) {
try {
await requireAuth();
} catch (e) {
if (e instanceof AuthError) return unauthorized();
throw e;
}
const body = await req.json();
const { tmdbId, type } = body;
if (!tmdbId || !type || !["movie", "tv"].includes(type)) {
return badRequest("tmdbId and type (movie|tv) are required");
}
const title = await importTitle(tmdbId, type);
return NextResponse.json(title);
}