mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
Docker self-hosting support: - Dockerfile (multi-stage Alpine build with tini init) - docker-compose.yml with named volume for SQLite persistence - /api/health endpoint for container health checks - Auto-migration on startup via drizzle-orm/libsql/migrator - Graceful shutdown (SIGTERM stops scheduler, closes DB) - Next.js standalone output mode for minimal image size Database driver migration (better-sqlite3 → @libsql/client): - Eliminates native C++ compilation, enabling Alpine Docker images - All DB queries converted from sync to async across services and routes - DATABASE_URL now uses libsql file: prefix format - drizzle.config.ts dialect changed to turso for libsql support - Initial migration files generated in drizzle/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
398 lines
11 KiB
TypeScript
398 lines
11 KiB
TypeScript
import { eq } from "drizzle-orm";
|
|
import { db } from "@/lib/db/client";
|
|
import {
|
|
episodes,
|
|
seasons,
|
|
titleRecommendations,
|
|
titles,
|
|
} from "@/lib/db/schema";
|
|
import {
|
|
getMovieDetails,
|
|
getRecommendations,
|
|
getSimilar,
|
|
getTvDetails,
|
|
getTvSeasonDetails,
|
|
} from "@/lib/tmdb/client";
|
|
import { refreshAvailability } from "./availability";
|
|
|
|
export async function importTitle(tmdbId: number, type: "movie" | "tv") {
|
|
const existing = await db
|
|
.select()
|
|
.from(titles)
|
|
.where(eq(titles.tmdbId, tmdbId))
|
|
.get();
|
|
if (existing) {
|
|
// For TV shows, check if seasons/episodes were actually loaded.
|
|
// They may be missing if a prior fetch failed or the title was created
|
|
// as a shell by the recommendations system (lastFetchedAt: null).
|
|
if (existing.type === "tv") {
|
|
const seasonCount = (
|
|
await db
|
|
.select()
|
|
.from(seasons)
|
|
.where(eq(seasons.titleId, existing.id))
|
|
.all()
|
|
).length;
|
|
if (seasonCount === 0) {
|
|
const show = await getTvDetails(tmdbId);
|
|
if (!existing.lastFetchedAt) {
|
|
await db
|
|
.update(titles)
|
|
.set({
|
|
overview: show.overview,
|
|
posterPath: show.poster_path,
|
|
backdropPath: show.backdrop_path,
|
|
status: show.status,
|
|
lastFetchedAt: new Date(),
|
|
})
|
|
.where(eq(titles.id, existing.id))
|
|
.run();
|
|
}
|
|
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
|
|
refreshAvailability(existing.id).catch(() => {});
|
|
refreshRecommendations(existing.id).catch(() => {});
|
|
return db.select().from(titles).where(eq(titles.id, existing.id)).get();
|
|
}
|
|
}
|
|
return existing;
|
|
}
|
|
|
|
const now = new Date();
|
|
|
|
if (type === "movie") {
|
|
const movie = await getMovieDetails(tmdbId);
|
|
const row = await db
|
|
.insert(titles)
|
|
.values({
|
|
tmdbId: movie.id,
|
|
type: "movie",
|
|
title: movie.title,
|
|
originalTitle: movie.original_title,
|
|
overview: movie.overview,
|
|
releaseDate: movie.release_date || null,
|
|
posterPath: movie.poster_path,
|
|
backdropPath: movie.backdrop_path,
|
|
popularity: movie.popularity,
|
|
voteAverage: movie.vote_average,
|
|
voteCount: movie.vote_count,
|
|
status: movie.status,
|
|
lastFetchedAt: now,
|
|
})
|
|
.returning()
|
|
.get();
|
|
// Fire-and-forget: fetch availability & recommendations
|
|
refreshAvailability(row.id).catch(() => {});
|
|
refreshRecommendations(row.id).catch(() => {});
|
|
return row;
|
|
}
|
|
|
|
const show = await getTvDetails(tmdbId);
|
|
const row = await db
|
|
.insert(titles)
|
|
.values({
|
|
tmdbId: show.id,
|
|
type: "tv",
|
|
title: show.name,
|
|
originalTitle: show.original_name,
|
|
overview: show.overview,
|
|
firstAirDate: show.first_air_date || null,
|
|
posterPath: show.poster_path,
|
|
backdropPath: show.backdrop_path,
|
|
popularity: show.popularity,
|
|
voteAverage: show.vote_average,
|
|
voteCount: show.vote_count,
|
|
status: show.status,
|
|
lastFetchedAt: now,
|
|
})
|
|
.returning()
|
|
.get();
|
|
|
|
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
|
|
// Fire-and-forget: fetch availability & recommendations
|
|
refreshAvailability(row.id).catch(() => {});
|
|
refreshRecommendations(row.id).catch(() => {});
|
|
return row;
|
|
}
|
|
|
|
export async function refreshTitle(titleId: string) {
|
|
const title = await db
|
|
.select()
|
|
.from(titles)
|
|
.where(eq(titles.id, titleId))
|
|
.get();
|
|
if (!title) return null;
|
|
|
|
const now = new Date();
|
|
|
|
if (title.type === "movie") {
|
|
const movie = await getMovieDetails(title.tmdbId);
|
|
await db
|
|
.update(titles)
|
|
.set({
|
|
title: movie.title,
|
|
originalTitle: movie.original_title,
|
|
overview: movie.overview,
|
|
releaseDate: movie.release_date || null,
|
|
posterPath: movie.poster_path,
|
|
backdropPath: movie.backdrop_path,
|
|
popularity: movie.popularity,
|
|
voteAverage: movie.vote_average,
|
|
voteCount: movie.vote_count,
|
|
status: movie.status,
|
|
lastFetchedAt: now,
|
|
})
|
|
.where(eq(titles.id, titleId))
|
|
.run();
|
|
} else {
|
|
const show = await getTvDetails(title.tmdbId);
|
|
await db
|
|
.update(titles)
|
|
.set({
|
|
title: show.name,
|
|
originalTitle: show.original_name,
|
|
overview: show.overview,
|
|
firstAirDate: show.first_air_date || null,
|
|
posterPath: show.poster_path,
|
|
backdropPath: show.backdrop_path,
|
|
popularity: show.popularity,
|
|
voteAverage: show.vote_average,
|
|
voteCount: show.vote_count,
|
|
status: show.status,
|
|
lastFetchedAt: now,
|
|
})
|
|
.where(eq(titles.id, titleId))
|
|
.run();
|
|
await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons);
|
|
}
|
|
|
|
return db.select().from(titles).where(eq(titles.id, titleId)).get();
|
|
}
|
|
|
|
export async function refreshTvChildren(
|
|
titleId: string,
|
|
tmdbId: number,
|
|
numberOfSeasons: number,
|
|
) {
|
|
const now = new Date();
|
|
|
|
for (let sn = 1; sn <= numberOfSeasons; sn++) {
|
|
// Rate-limit: 250ms between TMDB calls
|
|
if (sn > 1) await delay(250);
|
|
|
|
try {
|
|
const seasonData = await getTvSeasonDetails(tmdbId, sn);
|
|
|
|
const seasonRow = await db
|
|
.insert(seasons)
|
|
.values({
|
|
titleId,
|
|
seasonNumber: seasonData.season_number,
|
|
name: seasonData.name,
|
|
overview: seasonData.overview,
|
|
posterPath: seasonData.poster_path,
|
|
airDate: seasonData.air_date,
|
|
lastFetchedAt: now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [seasons.titleId, seasons.seasonNumber],
|
|
set: {
|
|
name: seasonData.name,
|
|
overview: seasonData.overview,
|
|
posterPath: seasonData.poster_path,
|
|
airDate: seasonData.air_date,
|
|
lastFetchedAt: now,
|
|
},
|
|
})
|
|
.returning()
|
|
.get();
|
|
|
|
for (const ep of seasonData.episodes) {
|
|
await db
|
|
.insert(episodes)
|
|
.values({
|
|
seasonId: seasonRow.id,
|
|
episodeNumber: ep.episode_number,
|
|
name: ep.name,
|
|
overview: ep.overview,
|
|
stillPath: ep.still_path,
|
|
airDate: ep.air_date,
|
|
runtimeMinutes: ep.runtime,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [episodes.seasonId, episodes.episodeNumber],
|
|
set: {
|
|
name: ep.name,
|
|
overview: ep.overview,
|
|
stillPath: ep.still_path,
|
|
airDate: ep.air_date,
|
|
runtimeMinutes: ep.runtime,
|
|
},
|
|
})
|
|
.run();
|
|
}
|
|
} catch {
|
|
// Skip this season and continue with the rest — partial data is
|
|
// better than aborting entirely. The next refresh cycle will retry.
|
|
console.error(`Failed to fetch season ${sn} for TMDB ${tmdbId}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function refreshRecommendations(titleId: string) {
|
|
const title = await db
|
|
.select()
|
|
.from(titles)
|
|
.where(eq(titles.id, titleId))
|
|
.get();
|
|
if (!title) return;
|
|
|
|
const now = new Date();
|
|
|
|
// Fetch both recommendations and similar
|
|
const [recs, similar] = await Promise.all([
|
|
getRecommendations(title.tmdbId, title.type),
|
|
getSimilar(title.tmdbId, title.type),
|
|
]);
|
|
|
|
// Process recommendations
|
|
for (let i = 0; i < recs.results.length && i < 20; i++) {
|
|
const r = recs.results[i];
|
|
const type = r.media_type ?? title.type;
|
|
if (type !== "movie" && type !== "tv") continue;
|
|
|
|
// Minimal upsert of the recommended title
|
|
const existing = await db
|
|
.select()
|
|
.from(titles)
|
|
.where(eq(titles.tmdbId, r.id))
|
|
.get();
|
|
let recTitleId: string;
|
|
if (existing) {
|
|
recTitleId = existing.id;
|
|
} else {
|
|
const row = await db
|
|
.insert(titles)
|
|
.values({
|
|
tmdbId: r.id,
|
|
type,
|
|
title: r.title ?? r.name ?? "Unknown",
|
|
originalTitle: r.original_title ?? r.original_name,
|
|
overview: r.overview,
|
|
releaseDate: r.release_date,
|
|
firstAirDate: r.first_air_date,
|
|
posterPath: r.poster_path,
|
|
backdropPath: r.backdrop_path,
|
|
popularity: r.popularity,
|
|
voteAverage: r.vote_average,
|
|
voteCount: r.vote_count,
|
|
lastFetchedAt: null,
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning()
|
|
.get();
|
|
if (!row) {
|
|
const found = await db
|
|
.select()
|
|
.from(titles)
|
|
.where(eq(titles.tmdbId, r.id))
|
|
.get();
|
|
if (!found) continue;
|
|
recTitleId = found.id;
|
|
} else {
|
|
recTitleId = row.id;
|
|
}
|
|
}
|
|
|
|
await db
|
|
.insert(titleRecommendations)
|
|
.values({
|
|
titleId,
|
|
recommendedTitleId: recTitleId,
|
|
source: "tmdb_recommendations",
|
|
rank: i + 1,
|
|
lastFetchedAt: now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
titleRecommendations.titleId,
|
|
titleRecommendations.recommendedTitleId,
|
|
titleRecommendations.source,
|
|
],
|
|
set: { rank: i + 1, lastFetchedAt: now },
|
|
})
|
|
.run();
|
|
}
|
|
|
|
// Process similar
|
|
for (let i = 0; i < similar.results.length && i < 20; i++) {
|
|
const r = similar.results[i];
|
|
const type = r.media_type ?? title.type;
|
|
if (type !== "movie" && type !== "tv") continue;
|
|
|
|
const existing = await db
|
|
.select()
|
|
.from(titles)
|
|
.where(eq(titles.tmdbId, r.id))
|
|
.get();
|
|
let recTitleId: string;
|
|
if (existing) {
|
|
recTitleId = existing.id;
|
|
} else {
|
|
const row = await db
|
|
.insert(titles)
|
|
.values({
|
|
tmdbId: r.id,
|
|
type,
|
|
title: r.title ?? r.name ?? "Unknown",
|
|
originalTitle: r.original_title ?? r.original_name,
|
|
overview: r.overview,
|
|
releaseDate: r.release_date,
|
|
firstAirDate: r.first_air_date,
|
|
posterPath: r.poster_path,
|
|
backdropPath: r.backdrop_path,
|
|
popularity: r.popularity,
|
|
voteAverage: r.vote_average,
|
|
voteCount: r.vote_count,
|
|
lastFetchedAt: null,
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning()
|
|
.get();
|
|
if (!row) {
|
|
const found = await db
|
|
.select()
|
|
.from(titles)
|
|
.where(eq(titles.tmdbId, r.id))
|
|
.get();
|
|
if (!found) continue;
|
|
recTitleId = found.id;
|
|
} else {
|
|
recTitleId = row.id;
|
|
}
|
|
}
|
|
|
|
await db
|
|
.insert(titleRecommendations)
|
|
.values({
|
|
titleId,
|
|
recommendedTitleId: recTitleId,
|
|
source: "tmdb_similar",
|
|
rank: i + 1,
|
|
lastFetchedAt: now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
titleRecommendations.titleId,
|
|
titleRecommendations.recommendedTitleId,
|
|
titleRecommendations.source,
|
|
],
|
|
set: { rank: i + 1, lastFetchedAt: now },
|
|
})
|
|
.run();
|
|
}
|
|
}
|
|
|
|
function delay(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|