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,7 @@
|
||||
import { registerJobs } from "./registry";
|
||||
import { scheduler } from "./scheduler";
|
||||
|
||||
export function initJobs() {
|
||||
registerJobs();
|
||||
scheduler.start();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user