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
+7
View File
@@ -0,0 +1,7 @@
import { registerJobs } from "./registry";
import { scheduler } from "./scheduler";
export function initJobs() {
registerJobs();
scheduler.start();
}
+163
View File
@@ -0,0 +1,163 @@
import { and, eq, isNotNull, lt, or } from "drizzle-orm";
import { db } from "@/lib/db/client";
import {
availabilityOffers,
seasons,
titles,
userTitleStatus,
} from "@/lib/db/schema";
import { refreshAvailability } from "@/lib/services/availability";
import {
refreshRecommendations,
refreshTitle,
refreshTvChildren,
} from "@/lib/services/metadata";
import { getTvDetails } from "@/lib/tmdb/client";
import { scheduler } from "./scheduler";
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const RATE_LIMIT_MS = 300;
function delay(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
function getLibraryTitleIds(): string[] {
return db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.groupBy(userTitleStatus.titleId)
.all()
.map((r) => r.titleId);
}
// Refresh titles where lastFetchedAt is stale
async function nightlyRefreshLibrary() {
const libraryIds = getLibraryTitleIds();
const libraryStale = new Date(Date.now() - 7 * DAY);
const nonLibraryStale = new Date(Date.now() - 30 * DAY);
// Library titles: 7 days
for (const titleId of libraryIds) {
const t = db
.select()
.from(titles)
.where(
and(eq(titles.id, titleId), lt(titles.lastFetchedAt, libraryStale)),
)
.get();
if (t) {
await refreshTitle(titleId);
await delay(RATE_LIMIT_MS);
}
}
// Non-library titles: 30 days
const nonLibrary = db
.select()
.from(titles)
.where(
and(
isNotNull(titles.lastFetchedAt),
lt(titles.lastFetchedAt, nonLibraryStale),
),
)
.limit(50)
.all();
for (const t of nonLibrary) {
if (!libraryIds.includes(t.id)) {
await refreshTitle(t.id);
await delay(RATE_LIMIT_MS);
}
}
}
// Refresh availability for library titles where stale
async function refreshAvailabilityJob() {
const libraryIds = getLibraryTitleIds();
const stale = new Date(Date.now() - DAY);
for (const titleId of libraryIds) {
// Check if any offer is stale
const offer = db
.select()
.from(availabilityOffers)
.where(
and(
eq(availabilityOffers.titleId, titleId),
lt(availabilityOffers.lastFetchedAt, stale),
),
)
.get();
// Also handle titles with no offers yet
const anyOffer = db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, titleId))
.get();
if (offer || !anyOffer) {
await refreshAvailability(titleId);
await delay(RATE_LIMIT_MS);
}
}
}
// Refresh recommendations for recently active titles
async function refreshRecommendationsJob() {
const libraryIds = getLibraryTitleIds();
for (const titleId of libraryIds) {
await refreshRecommendations(titleId);
await delay(RATE_LIMIT_MS);
}
}
// Refresh TV episodes for returning shows
async function refreshTvChildrenJob() {
const returningStatuses = ["Returning Series", "In Production"];
const stale = new Date(Date.now() - 7 * DAY);
const tvShows = db
.select()
.from(titles)
.where(
and(
eq(titles.type, "tv"),
isNotNull(titles.lastFetchedAt),
or(...returningStatuses.map((s) => eq(titles.status, s))),
),
)
.all();
for (const show of tvShows) {
// Check if seasons are stale
const staleSeason = db
.select()
.from(seasons)
.where(
and(eq(seasons.titleId, show.id), lt(seasons.lastFetchedAt, stale)),
)
.get();
if (staleSeason) {
const details = await getTvDetails(show.tmdbId);
await refreshTvChildren(show.id, show.tmdbId, details.number_of_seasons);
await delay(RATE_LIMIT_MS);
}
}
}
export function registerJobs() {
scheduler.register("nightlyRefreshLibrary", nightlyRefreshLibrary, 24 * HOUR);
scheduler.register("refreshAvailability", refreshAvailabilityJob, 6 * HOUR);
scheduler.register(
"refreshRecommendations",
refreshRecommendationsJob,
12 * HOUR,
);
scheduler.register("refreshTvChildren", refreshTvChildrenJob, 12 * HOUR);
}
+61
View File
@@ -0,0 +1,61 @@
interface Job {
name: string;
handler: () => Promise<void>;
intervalMs: number;
timer?: ReturnType<typeof setInterval>;
}
const globalForScheduler = globalThis as unknown as {
_scheduler: Scheduler | undefined;
};
class Scheduler {
private jobs = new Map<string, Job>();
private running = false;
register(name: string, handler: () => Promise<void>, intervalMs: number) {
this.jobs.set(name, { name, handler, intervalMs });
}
start() {
if (this.running) return;
this.running = true;
for (const job of this.jobs.values()) {
job.timer = setInterval(async () => {
try {
console.log(`[scheduler] Running job: ${job.name}`);
await job.handler();
console.log(`[scheduler] Completed job: ${job.name}`);
} catch (err) {
console.error(`[scheduler] Job ${job.name} failed:`, err);
}
}, job.intervalMs);
}
console.log(`[scheduler] Started ${this.jobs.size} jobs`);
}
stop() {
for (const job of this.jobs.values()) {
if (job.timer) clearInterval(job.timer);
}
this.running = false;
}
async runNow(name: string) {
const job = this.jobs.get(name);
if (!job) throw new Error(`Job not found: ${name}`);
await job.handler();
}
getJobNames() {
return [...this.jobs.keys()];
}
}
if (!globalForScheduler._scheduler) {
globalForScheduler._scheduler = new Scheduler();
}
export const scheduler = globalForScheduler._scheduler;