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
+96
View File
@@ -0,0 +1,96 @@
import type {
TmdbMovieDetails,
TmdbRecommendationResponse,
TmdbSearchResponse,
TmdbSeasonDetails,
TmdbTvDetails,
TmdbWatchProviderResponse,
} from "./types";
const BASE_URL = "https://api.themoviedb.org/3";
function getApiKey() {
const key = process.env.TMDB_API_KEY;
if (!key) throw new Error("TMDB_API_KEY is not set");
return key;
}
async function tmdbFetch<T>(
path: string,
params?: Record<string, string>,
): Promise<T> {
const url = new URL(`${BASE_URL}${path}`);
if (params) {
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, v);
}
}
const res = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${getApiKey()}`,
Accept: "application/json",
},
});
if (!res.ok) {
throw new Error(`TMDB API error: ${res.status} ${res.statusText}`);
}
return res.json() as Promise<T>;
}
export async function searchMulti(query: string, page = 1) {
return tmdbFetch<TmdbSearchResponse>("/search/multi", {
query,
page: String(page),
include_adult: "false",
});
}
export async function searchMovies(query: string, page = 1) {
return tmdbFetch<TmdbSearchResponse>("/search/movie", {
query,
page: String(page),
});
}
export async function searchTv(query: string, page = 1) {
return tmdbFetch<TmdbSearchResponse>("/search/tv", {
query,
page: String(page),
});
}
export async function getMovieDetails(tmdbId: number) {
return tmdbFetch<TmdbMovieDetails>(`/movie/${tmdbId}`);
}
export async function getTvDetails(tmdbId: number) {
return tmdbFetch<TmdbTvDetails>(`/tv/${tmdbId}`);
}
export async function getTvSeasonDetails(tmdbId: number, seasonNumber: number) {
return tmdbFetch<TmdbSeasonDetails>(`/tv/${tmdbId}/season/${seasonNumber}`);
}
export async function getWatchProviders(tmdbId: number, type: "movie" | "tv") {
return tmdbFetch<TmdbWatchProviderResponse>(
`/${type}/${tmdbId}/watch/providers`,
);
}
export async function getRecommendations(tmdbId: number, type: "movie" | "tv") {
return tmdbFetch<TmdbRecommendationResponse>(
`/${type}/${tmdbId}/recommendations`,
);
}
export async function getSimilar(tmdbId: number, type: "movie" | "tv") {
return tmdbFetch<TmdbRecommendationResponse>(`/${type}/${tmdbId}/similar`);
}
export function tmdbImageUrl(path: string | null, size = "w500") {
if (!path) return null;
return `https://image.tmdb.org/t/p/${size}${path}`;
}