mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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:
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user