Remove unnecessary await/async from sync bun:sqlite db calls

drizzle-orm/bun-sqlite is fully synchronous — all queries return values
directly, not promises. Remove await from all db calls, drop async from
functions that no longer need it, simplify Promise.all patterns that
wrapped sync operations, and fix setSetting() which was missing .run()
(previously masked by await triggering execution via thenable).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 19:04:40 -05:00
co-authored by Claude Opus 4.6
parent 0f345c8cd6
commit d9b408128b
14 changed files with 275 additions and 379 deletions
+12 -12
View File
@@ -58,8 +58,8 @@ function delay(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
async function getLibraryTitleIds(): Promise<string[]> {
const rows = await db
function getLibraryTitleIds(): string[] {
const rows = db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.groupBy(userTitleStatus.titleId)
@@ -69,13 +69,13 @@ async function getLibraryTitleIds(): Promise<string[]> {
// Refresh titles where lastFetchedAt is stale
async function nightlyRefreshLibrary() {
const libraryIds = await getLibraryTitleIds();
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 = await db
const t = db
.select()
.from(titles)
.where(
@@ -89,7 +89,7 @@ async function nightlyRefreshLibrary() {
}
// Non-library titles: 30 days
const nonLibrary = await db
const nonLibrary = db
.select()
.from(titles)
.where(
@@ -111,12 +111,12 @@ async function nightlyRefreshLibrary() {
// Refresh availability for library titles where stale
async function refreshAvailabilityJob() {
const libraryIds = await getLibraryTitleIds();
const libraryIds = getLibraryTitleIds();
const stale = new Date(Date.now() - DAY);
for (const titleId of libraryIds) {
// Check if any offer is stale
const offer = await db
const offer = db
.select()
.from(availabilityOffers)
.where(
@@ -128,7 +128,7 @@ async function refreshAvailabilityJob() {
.get();
// Also handle titles with no offers yet
const anyOffer = await db
const anyOffer = db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, titleId))
@@ -143,7 +143,7 @@ async function refreshAvailabilityJob() {
// Refresh recommendations for recently active titles
async function refreshRecommendationsJob() {
const libraryIds = await getLibraryTitleIds();
const libraryIds = getLibraryTitleIds();
for (const titleId of libraryIds) {
await refreshRecommendations(titleId);
@@ -156,7 +156,7 @@ async function refreshTvChildrenJob() {
const returningStatuses = ["Returning Series", "In Production"];
const stale = new Date(Date.now() - 7 * DAY);
const tvShows = await db
const tvShows = db
.select()
.from(titles)
.where(
@@ -170,7 +170,7 @@ async function refreshTvChildrenJob() {
for (const show of tvShows) {
// Check if seasons are stale
const staleSeason = await db
const staleSeason = db
.select()
.from(seasons)
.where(
@@ -190,7 +190,7 @@ async function refreshTvChildrenJob() {
async function cacheImagesJob() {
if (!imageCacheEnabled()) return;
const libraryIds = await getLibraryTitleIds();
const libraryIds = getLibraryTitleIds();
for (const titleId of libraryIds) {
try {
+4 -10
View File
@@ -4,11 +4,7 @@ import { availabilityOffers, titles } from "@/lib/db/schema";
import { getWatchProviders } from "@/lib/tmdb/client";
export async function refreshAvailability(titleId: string) {
const title = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return;
const data = await getWatchProviders(title.tmdbId, title.type);
@@ -19,8 +15,7 @@ export async function refreshAvailability(titleId: string) {
const offerTypes = ["flatrate", "rent", "buy", "free", "ads"] as const;
// Delete existing offers for this title+region
await db
.delete(availabilityOffers)
db.delete(availabilityOffers)
.where(
and(
eq(availabilityOffers.titleId, titleId),
@@ -34,8 +29,7 @@ export async function refreshAvailability(titleId: string) {
if (!providers) continue;
for (const p of providers) {
await db
.insert(availabilityOffers)
db.insert(availabilityOffers)
.values({
titleId,
region: "US",
@@ -52,7 +46,7 @@ export async function refreshAvailability(titleId: string) {
}
}
export async function getAvailability(titleId: string) {
export function getAvailability(titleId: string) {
return db
.select()
.from(availabilityOffers)
+1 -2
View File
@@ -51,8 +51,7 @@ export async function extractAndStoreColors(
lightMuted: palette.LightMuted?.hex ?? null,
};
await db
.update(titles)
db.update(titles)
.set({ colorPalette: JSON.stringify(colors) })
.where(eq(titles.id, titleId))
.run();
+59 -80
View File
@@ -20,7 +20,7 @@ export interface DashboardStats {
completed: number;
}
export async function getUserStats(userId: string): Promise<DashboardStats> {
export function getUserStats(userId: string): DashboardStats {
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const dayOfWeek = now.getDay();
@@ -28,7 +28,7 @@ export async function getUserStats(userId: string): Promise<DashboardStats> {
weekStart.setDate(now.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
weekStart.setHours(0, 0, 0, 0);
const [moviesThisMonth] = await db
const [moviesThisMonth] = db
.select({ count: sql<number>`count(*)` })
.from(userMovieWatches)
.where(
@@ -39,7 +39,7 @@ export async function getUserStats(userId: string): Promise<DashboardStats> {
)
.all();
const [episodesThisWeek] = await db
const [episodesThisWeek] = db
.select({ count: sql<number>`count(*)` })
.from(userEpisodeWatches)
.where(
@@ -50,13 +50,13 @@ export async function getUserStats(userId: string): Promise<DashboardStats> {
)
.all();
const [librarySizeRow] = await db
const [librarySizeRow] = db
.select({ count: sql<number>`count(*)` })
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all();
const [completedCount] = await db
const [completedCount] = db
.select({ count: sql<number>`count(*)` })
.from(userTitleStatus)
.where(
@@ -96,11 +96,11 @@ export interface ContinueWatchingItem {
watchedEpisodes: number;
}
export async function getContinueWatchingFeed(
export function getContinueWatchingFeed(
userId: string,
): Promise<ContinueWatchingItem[]> {
): ContinueWatchingItem[] {
// Get in-progress TV shows
const inProgress = await db
const inProgress = db
.select({
titleId: userTitleStatus.titleId,
updatedAt: userTitleStatus.updatedAt,
@@ -118,7 +118,7 @@ export async function getContinueWatchingFeed(
const today = new Date().toISOString().slice(0, 10);
for (const row of inProgress) {
const title = await db
const title = db
.select()
.from(titles)
.where(and(eq(titles.id, row.titleId), eq(titles.type, "tv")))
@@ -126,7 +126,7 @@ export async function getContinueWatchingFeed(
if (!title) continue;
// Get all seasons for this title, ordered
const titleSeasons = await db
const titleSeasons = db
.select()
.from(seasons)
.where(eq(seasons.titleId, title.id))
@@ -141,7 +141,7 @@ export async function getContinueWatchingFeed(
// Get most recent watch for this show
for (const s of titleSeasons) {
const eps = await db
const eps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
@@ -151,7 +151,7 @@ export async function getContinueWatchingFeed(
totalEpisodes += eps.length;
for (const ep of eps) {
const watch = await db
const watch = db
.select()
.from(userEpisodeWatches)
.where(
@@ -210,10 +210,10 @@ export async function getContinueWatchingFeed(
}
// biome-ignore lint/correctness/noUnusedFunctionParameters: days reserved for future date filtering
export async function getNewAvailableFeed(userId: string, days = 14) {
export function getNewAvailableFeed(userId: string, days = 14) {
// Get titles the user has in any status that have availability offers
// and recent release/air dates
const results = await db
const results = db
.select({
titleId: titles.id,
title: titles.title,
@@ -243,52 +243,46 @@ export async function getNewAvailableFeed(userId: string, days = 14) {
return results;
}
export async function getRecommendationsFeed(userId: string) {
export function getRecommendationsFeed(userId: string) {
// Get recommendations from user's highly-rated or completed titles
const userCompletedOrRated = (
await db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.status, "completed"),
),
)
.all()
).map((r) => r.titleId);
const userCompletedOrRated = db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
eq(userTitleStatus.status, "completed"),
),
)
.all()
.map((r) => r.titleId);
const ratedIds = (
await db
.select({ titleId: userRatings.titleId })
.from(userRatings)
.where(
and(
eq(userRatings.userId, userId),
sql`${userRatings.ratingStars} >= 4`,
),
)
.all()
).map((r) => r.titleId);
const ratedIds = db
.select({ titleId: userRatings.titleId })
.from(userRatings)
.where(
and(eq(userRatings.userId, userId), sql`${userRatings.ratingStars} >= 4`),
)
.all()
.map((r) => r.titleId);
const sourceIds = [...new Set([...userCompletedOrRated, ...ratedIds])];
if (sourceIds.length === 0) return [];
// Get all tracked title IDs to exclude
const trackedIds = new Set(
(
await db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all()
).map((r) => r.titleId),
db
.select({ titleId: userTitleStatus.titleId })
.from(userTitleStatus)
.where(eq(userTitleStatus.userId, userId))
.all()
.map((r) => r.titleId),
);
const recs: Map<string, { titleId: string; score: number }> = new Map();
for (const sourceId of sourceIds) {
const recRows = await db
const recRows = db
.select({
recommendedTitleId: titleRecommendations.recommendedTitleId,
rank: titleRecommendations.rank,
@@ -316,29 +310,16 @@ export async function getRecommendationsFeed(userId: string) {
.sort((a, b) => b.score - a.score)
.slice(0, 20);
return (
await Promise.all(
sorted.map(async (r) => {
const title = await db
.select()
.from(titles)
.where(eq(titles.id, r.titleId))
.get();
return title;
}),
)
).filter(Boolean);
return sorted
.map((r) => db.select().from(titles).where(eq(titles.id, r.titleId)).get())
.filter(Boolean);
}
export async function getRecommendationsForTitle(titleId: string) {
const title = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
export function getRecommendationsForTitle(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return [];
const recs = await db
const recs = db
.select({
recommendedTitleId: titleRecommendations.recommendedTitleId,
source: titleRecommendations.source,
@@ -349,20 +330,18 @@ export async function getRecommendationsForTitle(titleId: string) {
.orderBy(titleRecommendations.rank)
.all();
const results = (
await Promise.all(
recs.map(async (rec) => {
const recTitle = await db
.select()
.from(titles)
.where(eq(titles.id, rec.recommendedTitleId))
.get();
return recTitle
? { ...recTitle, source: rec.source, rank: rec.rank }
: null;
}),
)
).filter((r): r is NonNullable<typeof r> => r !== null);
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((r): r is NonNullable<typeof r> => r !== null);
return results.map((r) => ({
id: r.id,
+5 -9
View File
@@ -121,11 +121,7 @@ export async function fetchAndMaybeCache(
}
export async function cacheImagesForTitle(titleId: string) {
const title = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return;
const tasks: Promise<unknown>[] = [];
@@ -145,7 +141,7 @@ export async function cacheImagesForTitle(titleId: string) {
// Season posters
if (title.type === "tv") {
const allSeasons = await db
const allSeasons = db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
@@ -164,14 +160,14 @@ export async function cacheImagesForTitle(titleId: string) {
}
export async function cacheEpisodeStills(titleId: string) {
const allSeasons = await db
const allSeasons = db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
for (const s of allSeasons) {
const eps = await db
const eps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
@@ -191,7 +187,7 @@ export async function cacheEpisodeStills(titleId: string) {
}
export async function cacheProviderLogos(titleId: string) {
const offers = await db
const offers = db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, titleId))
+55 -85
View File
@@ -35,7 +35,7 @@ export async function importTitle(
options?: { awaitEnrichment?: boolean },
) {
const awaitEnrichment = options?.awaitEnrichment ?? false;
const existing = await db
const existing = db
.select()
.from(titles)
.where(eq(titles.tmdbId, tmdbId))
@@ -45,18 +45,15 @@ export async function importTitle(
// 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;
const seasonCount = 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)
db.update(titles)
.set({
overview: show.overview,
posterPath: show.poster_path,
@@ -93,7 +90,7 @@ export async function importTitle(
if (type === "movie") {
const movie = await getMovieDetails(tmdbId);
const row = await db
const row = db
.insert(titles)
.values({
tmdbId: movie.id,
@@ -126,7 +123,7 @@ export async function importTitle(
}
const show = await getTvDetails(tmdbId);
const row = await db
const row = db
.insert(titles)
.values({
tmdbId: show.id,
@@ -164,19 +161,14 @@ export async function importTitle(
}
export async function refreshTitle(titleId: string) {
const title = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
const title = 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)
db.update(titles)
.set({
title: movie.title,
originalTitle: movie.original_title,
@@ -194,8 +186,7 @@ export async function refreshTitle(titleId: string) {
.run();
} else {
const show = await getTvDetails(title.tmdbId);
await db
.update(titles)
db.update(titles)
.set({
title: show.name,
originalTitle: show.original_name,
@@ -214,11 +205,7 @@ export async function refreshTitle(titleId: string) {
await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons);
}
const updated = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
const updated = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (updated) {
extractAndStoreColors(updated.id, updated.posterPath).catch(() => {});
if (imageCacheEnabled()) {
@@ -243,7 +230,7 @@ export async function refreshTvChildren(
try {
const seasonData = await getTvSeasonDetails(tmdbId, sn);
const seasonRow = await db
const seasonRow = db
.insert(seasons)
.values({
titleId,
@@ -268,8 +255,7 @@ export async function refreshTvChildren(
.get();
for (const ep of seasonData.episodes) {
await db
.insert(episodes)
db.insert(episodes)
.values({
seasonId: seasonRow.id,
episodeNumber: ep.episode_number,
@@ -300,11 +286,7 @@ export async function refreshTvChildren(
}
export async function refreshRecommendations(titleId: string) {
const title = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return;
const now = new Date();
@@ -322,7 +304,7 @@ export async function refreshRecommendations(titleId: string) {
if (type !== "movie" && type !== "tv") continue;
// Minimal upsert of the recommended title
const existing = await db
const existing = db
.select()
.from(titles)
.where(eq(titles.tmdbId, r.id))
@@ -331,7 +313,7 @@ export async function refreshRecommendations(titleId: string) {
if (existing) {
recTitleId = existing.id;
} else {
const row = await db
const row = db
.insert(titles)
.values({
tmdbId: r.id,
@@ -352,7 +334,7 @@ export async function refreshRecommendations(titleId: string) {
.returning()
.get();
if (!row) {
const found = await db
const found = db
.select()
.from(titles)
.where(eq(titles.tmdbId, r.id))
@@ -364,8 +346,7 @@ export async function refreshRecommendations(titleId: string) {
}
}
await db
.insert(titleRecommendations)
db.insert(titleRecommendations)
.values({
titleId,
recommendedTitleId: recTitleId,
@@ -390,7 +371,7 @@ export async function refreshRecommendations(titleId: string) {
const type = r.media_type ?? title.type;
if (type !== "movie" && type !== "tv") continue;
const existing = await db
const existing = db
.select()
.from(titles)
.where(eq(titles.tmdbId, r.id))
@@ -399,7 +380,7 @@ export async function refreshRecommendations(titleId: string) {
if (existing) {
recTitleId = existing.id;
} else {
const row = await db
const row = db
.insert(titles)
.values({
tmdbId: r.id,
@@ -420,7 +401,7 @@ export async function refreshRecommendations(titleId: string) {
.returning()
.get();
if (!row) {
const found = await db
const found = db
.select()
.from(titles)
.where(eq(titles.tmdbId, r.id))
@@ -432,8 +413,7 @@ export async function refreshRecommendations(titleId: string) {
}
}
await db
.insert(titleRecommendations)
db.insert(titleRecommendations)
.values({
titleId,
recommendedTitleId: recTitleId,
@@ -458,15 +438,14 @@ export async function getTitleWithChildren(id: string): Promise<{
seasons: Season[];
availability: AvailabilityOffer[];
} | null> {
let title = await db.select().from(titles).where(eq(titles.id, id)).get();
let title = db.select().from(titles).where(eq(titles.id, id)).get();
if (!title) return null;
// If this is a shell TV title, fetch full details now
if (title.type === "tv" && !title.lastFetchedAt) {
try {
const show = await getTvDetails(title.tmdbId);
await db
.update(titles)
db.update(titles)
.set({
overview: show.overview,
posterPath: show.poster_path,
@@ -477,9 +456,7 @@ export async function getTitleWithChildren(id: string): Promise<{
.where(eq(titles.id, id))
.run();
await refreshTvChildren(id, title.tmdbId, show.number_of_seasons);
title =
(await db.select().from(titles).where(eq(titles.id, id)).get()) ??
title;
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
} catch {
// Continue with whatever data we have
}
@@ -489,8 +466,7 @@ export async function getTitleWithChildren(id: string): Promise<{
if (title.type === "movie" && !title.lastFetchedAt) {
try {
const movie = await getMovieDetails(title.tmdbId);
await db
.update(titles)
db.update(titles)
.set({
title: movie.title,
originalTitle: movie.original_title,
@@ -506,9 +482,7 @@ export async function getTitleWithChildren(id: string): Promise<{
})
.where(eq(titles.id, id))
.run();
title =
(await db.select().from(titles).where(eq(titles.id, id)).get()) ??
title;
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
} catch {
// Continue with whatever data we have
}
@@ -517,26 +491,24 @@ export async function getTitleWithChildren(id: string): Promise<{
let titleSeasons: Season[] = [];
if (title.type === "tv") {
const seasonRows = await db
const seasonRows = db
.select()
.from(seasons)
.where(eq(seasons.titleId, title.id))
.orderBy(seasons.seasonNumber)
.all();
titleSeasons = await Promise.all(
seasonRows.map(async (s) => ({
id: s.id,
seasonNumber: s.seasonNumber,
name: s.name,
episodes: (
await db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.orderBy(episodes.episodeNumber)
.all()
).map(
titleSeasons = seasonRows.map((s) => ({
id: s.id,
seasonNumber: s.seasonNumber,
name: s.name,
episodes: db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.orderBy(episodes.episodeNumber)
.all()
.map(
(ep): Episode => ({
id: ep.id,
episodeNumber: ep.episodeNumber,
@@ -547,24 +519,22 @@ export async function getTitleWithChildren(id: string): Promise<{
runtimeMinutes: ep.runtimeMinutes,
}),
),
})),
);
}));
}
const availability = (
await db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, title.id))
.all()
).map(
(a): AvailabilityOffer => ({
providerId: a.providerId,
providerName: a.providerName,
logoPath: tmdbImageUrl(a.logoPath, "w92"),
offerType: a.offerType,
}),
);
const availability = db
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, title.id))
.all()
.map(
(a): AvailabilityOffer => ({
providerId: a.providerId,
providerName: a.providerName,
logoPath: tmdbImageUrl(a.logoPath, "w92"),
offerType: a.offerType,
}),
);
// Lazy color extraction — await so the palette is available for theming
let palette = parseColorPalette(title.colorPalette);
+11 -11
View File
@@ -2,8 +2,8 @@ import { count, eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { appSettings, user } from "@/lib/db/schema";
export async function getSetting(key: string): Promise<string | null> {
const row = await db
export function getSetting(key: string): string | null {
const row = db
.select()
.from(appSettings)
.where(eq(appSettings.key, key))
@@ -11,22 +11,22 @@ export async function getSetting(key: string): Promise<string | null> {
return row?.value ?? null;
}
export async function setSetting(key: string, value: string): Promise<void> {
await db
.insert(appSettings)
export function setSetting(key: string, value: string): void {
db.insert(appSettings)
.values({ key, value })
.onConflictDoUpdate({ target: appSettings.key, set: { value } });
.onConflictDoUpdate({ target: appSettings.key, set: { value } })
.run();
}
export async function getUserCount(): Promise<number> {
const result = await db.select({ count: count() }).from(user).get();
export function getUserCount(): number {
const result = db.select({ count: count() }).from(user).get();
return result?.count ?? 0;
}
export async function isRegistrationOpen(): Promise<boolean> {
const userCount = await getUserCount();
export function isRegistrationOpen(): boolean {
const userCount = getUserCount();
if (userCount === 0) return true;
const setting = await getSetting("registrationOpen");
const setting = getSetting("registrationOpen");
return setting === "true";
}
+49 -70
View File
@@ -10,7 +10,7 @@ import {
userTitleStatus,
} from "@/lib/db/schema";
export async function setTitleStatus(
export function setTitleStatus(
userId: string,
titleId: string,
status: "watchlist" | "in_progress" | "completed",
@@ -18,8 +18,7 @@ export async function setTitleStatus(
source: "manual" | "import" | "plex" | "jellyfin" = "manual",
) {
const now = new Date();
await db
.insert(userTitleStatus)
db.insert(userTitleStatus)
.values({ userId, titleId, status, addedAt: now, updatedAt: now })
.onConflictDoUpdate({
target: [userTitleStatus.userId, userTitleStatus.titleId],
@@ -28,9 +27,8 @@ export async function setTitleStatus(
.run();
}
export async function removeTitleStatus(userId: string, titleId: string) {
await db
.delete(userTitleStatus)
export function removeTitleStatus(userId: string, titleId: string) {
db.delete(userTitleStatus)
.where(
and(
eq(userTitleStatus.userId, userId),
@@ -40,19 +38,18 @@ export async function removeTitleStatus(userId: string, titleId: string) {
.run();
}
export async function logMovieWatch(
export function logMovieWatch(
userId: string,
titleId: string,
source: "manual" | "import" | "plex" | "jellyfin" = "manual",
) {
const now = new Date();
await db
.insert(userMovieWatches)
db.insert(userMovieWatches)
.values({ userId, titleId, watchedAt: now, source })
.run();
// Auto-set status to completed
const existing = await db
const existing = db
.select()
.from(userTitleStatus)
.where(
@@ -64,31 +61,26 @@ export async function logMovieWatch(
.get();
if (!existing) {
await setTitleStatus(userId, titleId, "completed", source);
setTitleStatus(userId, titleId, "completed", source);
} else if (existing.status !== "completed") {
await setTitleStatus(userId, titleId, "completed", source);
setTitleStatus(userId, titleId, "completed", source);
}
}
export async function logEpisodeWatch(
export function logEpisodeWatch(
userId: string,
episodeId: string,
source: "manual" | "import" | "plex" | "jellyfin" = "manual",
) {
const now = new Date();
await db
.insert(userEpisodeWatches)
db.insert(userEpisodeWatches)
.values({ userId, episodeId, watchedAt: now, source })
.run();
// Find the title for this episode
const ep = await db
.select()
.from(episodes)
.where(eq(episodes.id, episodeId))
.get();
const ep = db.select().from(episodes).where(eq(episodes.id, episodeId)).get();
if (!ep) return;
const season = await db
const season = db
.select()
.from(seasons)
.where(eq(seasons.id, ep.seasonId))
@@ -97,7 +89,7 @@ export async function logEpisodeWatch(
const titleId = season.titleId;
// Auto-set status to in_progress if not set
const existing = await db
const existing = db
.select()
.from(userTitleStatus)
.where(
@@ -109,41 +101,37 @@ export async function logEpisodeWatch(
.get();
if (!existing) {
await setTitleStatus(userId, titleId, "in_progress", source);
setTitleStatus(userId, titleId, "in_progress", source);
}
// Check if all episodes are watched -> auto-complete
await checkAllEpisodesWatched(userId, titleId);
checkAllEpisodesWatched(userId, titleId);
}
export async function markAllEpisodesWatched(
export function markAllEpisodesWatched(
userId: string,
titleId: string,
source: "manual" | "import" | "plex" | "jellyfin" = "manual",
) {
const title = await db
.select()
.from(titles)
.where(eq(titles.id, titleId))
.get();
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title || title.type !== "tv") return;
const now = new Date();
const allSeasons = await db
const allSeasons = db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
for (const s of allSeasons) {
const eps = await db
const eps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.all();
for (const ep of eps) {
const existing = await db
const existing = db
.select()
.from(userEpisodeWatches)
.where(
@@ -154,8 +142,7 @@ export async function markAllEpisodesWatched(
)
.get();
if (!existing) {
await db
.insert(userEpisodeWatches)
db.insert(userEpisodeWatches)
.values({
userId,
episodeId: ep.id,
@@ -167,11 +154,11 @@ export async function markAllEpisodesWatched(
}
}
await setTitleStatus(userId, titleId, "completed", source);
setTitleStatus(userId, titleId, "completed", source);
}
async function checkAllEpisodesWatched(userId: string, titleId: string) {
const allSeasons = await db
function checkAllEpisodesWatched(userId: string, titleId: string) {
const allSeasons = db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
@@ -181,7 +168,7 @@ async function checkAllEpisodesWatched(userId: string, titleId: string) {
let watchedEpisodes = 0;
for (const s of allSeasons) {
const eps = await db
const eps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
@@ -189,7 +176,7 @@ async function checkAllEpisodesWatched(userId: string, titleId: string) {
totalEpisodes += eps.length;
for (const ep of eps) {
const watch = await db
const watch = db
.select()
.from(userEpisodeWatches)
.where(
@@ -204,13 +191,12 @@ async function checkAllEpisodesWatched(userId: string, titleId: string) {
}
if (totalEpisodes > 0 && watchedEpisodes >= totalEpisodes) {
await setTitleStatus(userId, titleId, "completed");
setTitleStatus(userId, titleId, "completed");
}
}
export async function unwatchEpisode(userId: string, episodeId: string) {
await db
.delete(userEpisodeWatches)
export function unwatchEpisode(userId: string, episodeId: string) {
db.delete(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
@@ -220,20 +206,16 @@ export async function unwatchEpisode(userId: string, episodeId: string) {
.run();
// Find parent title and downgrade from completed to in_progress
const ep = await db
.select()
.from(episodes)
.where(eq(episodes.id, episodeId))
.get();
const ep = db.select().from(episodes).where(eq(episodes.id, episodeId)).get();
if (!ep) return;
const season = await db
const season = db
.select()
.from(seasons)
.where(eq(seasons.id, ep.seasonId))
.get();
if (!season) return;
const existing = await db
const existing = db
.select()
.from(userTitleStatus)
.where(
@@ -245,12 +227,12 @@ export async function unwatchEpisode(userId: string, episodeId: string) {
.get();
if (existing?.status === "completed") {
await setTitleStatus(userId, season.titleId, "in_progress");
setTitleStatus(userId, season.titleId, "in_progress");
}
}
export async function unwatchSeason(userId: string, seasonId: string) {
const seasonEps = await db
export function unwatchSeason(userId: string, seasonId: string) {
const seasonEps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, seasonId))
@@ -258,8 +240,7 @@ export async function unwatchSeason(userId: string, seasonId: string) {
const epIds = seasonEps.map((ep) => ep.id);
if (epIds.length > 0) {
await db
.delete(userEpisodeWatches)
db.delete(userEpisodeWatches)
.where(
and(
eq(userEpisodeWatches.userId, userId),
@@ -270,14 +251,14 @@ export async function unwatchSeason(userId: string, seasonId: string) {
}
// Find parent title and downgrade from completed to in_progress
const season = await db
const season = db
.select()
.from(seasons)
.where(eq(seasons.id, seasonId))
.get();
if (!season) return;
const existing = await db
const existing = db
.select()
.from(userTitleStatus)
.where(
@@ -289,27 +270,25 @@ export async function unwatchSeason(userId: string, seasonId: string) {
.get();
if (existing?.status === "completed") {
await setTitleStatus(userId, season.titleId, "in_progress");
setTitleStatus(userId, season.titleId, "in_progress");
}
}
export async function rateTitleStars(
export function rateTitleStars(
userId: string,
titleId: string,
ratingStars: number,
) {
const now = new Date();
if (ratingStars === 0) {
await db
.delete(userRatings)
db.delete(userRatings)
.where(
and(eq(userRatings.userId, userId), eq(userRatings.titleId, titleId)),
)
.run();
return;
}
await db
.insert(userRatings)
db.insert(userRatings)
.values({ userId, titleId, ratingStars, ratedAt: now })
.onConflictDoUpdate({
target: [userRatings.userId, userRatings.titleId],
@@ -318,8 +297,8 @@ export async function rateTitleStars(
.run();
}
export async function getUserTitleInfo(userId: string, titleId: string) {
const status = await db
export function getUserTitleInfo(userId: string, titleId: string) {
const status = db
.select()
.from(userTitleStatus)
.where(
@@ -330,7 +309,7 @@ export async function getUserTitleInfo(userId: string, titleId: string) {
)
.get();
const rating = await db
const rating = db
.select()
.from(userRatings)
.where(
@@ -339,7 +318,7 @@ export async function getUserTitleInfo(userId: string, titleId: string) {
.get();
// Get watched episode IDs for this title
const titleSeasons = await db
const titleSeasons = db
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
@@ -347,13 +326,13 @@ export async function getUserTitleInfo(userId: string, titleId: string) {
const watchedEpisodeIds: string[] = [];
for (const s of titleSeasons) {
const eps = await db
const eps = db
.select()
.from(episodes)
.where(eq(episodes.seasonId, s.id))
.all();
for (const ep of eps) {
const watch = await db
const watch = db
.select()
.from(userEpisodeWatches)
.where(
+29 -43
View File
@@ -197,12 +197,9 @@ async function resolveEpisode(event: WebhookEvent): Promise<{
// ─── Deduplication ──────────────────────────────────────────────────
async function isDuplicateMovieWatch(
userId: string,
titleId: string,
): Promise<boolean> {
function isDuplicateMovieWatch(userId: string, titleId: string): boolean {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
const recent = await db
const recent = db
.select()
.from(userMovieWatches)
.where(
@@ -216,12 +213,9 @@ async function isDuplicateMovieWatch(
return !!recent;
}
async function isDuplicateEpisodeWatch(
userId: string,
episodeId: string,
): Promise<boolean> {
function isDuplicateEpisodeWatch(userId: string, episodeId: string): boolean {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
const recent = await db
const recent = db
.select()
.from(userEpisodeWatches)
.where(
@@ -237,14 +231,13 @@ async function isDuplicateEpisodeWatch(
// ─── Event Logging ──────────────────────────────────────────────────
async function logEvent(
function logEvent(
connectionId: string,
event: WebhookEvent | null,
status: "success" | "ignored" | "error",
errorMessage?: string,
) {
await db
.insert(webhookEventLog)
db.insert(webhookEventLog)
.values({
connectionId,
eventType: event?.provider === "plex" ? "media.scrobble" : "PlaybackStop",
@@ -256,8 +249,7 @@ async function logEvent(
})
.run();
await db
.update(webhookConnections)
db.update(webhookConnections)
.set({ lastEventAt: new Date() })
.where(eq(webhookConnections.id, connectionId))
.run();
@@ -275,7 +267,7 @@ export async function processWebhook(
if (event.mediaType === "movie") {
const tmdbId = await resolveMovieTmdbId(event);
if (!tmdbId) {
await logEvent(
logEvent(
connectionId,
event,
"error",
@@ -286,12 +278,12 @@ export async function processWebhook(
const title = await importTitle(tmdbId, "movie");
if (!title) {
await logEvent(connectionId, event, "error", "Failed to import movie");
logEvent(connectionId, event, "error", "Failed to import movie");
return { status: "error", message: "Failed to import movie" };
}
if (await isDuplicateMovieWatch(userId, title.id)) {
await logEvent(
if (isDuplicateMovieWatch(userId, title.id)) {
logEvent(
connectionId,
event,
"ignored",
@@ -300,36 +292,26 @@ export async function processWebhook(
return { status: "ignored", message: "Duplicate watch" };
}
await logMovieWatch(userId, title.id, provider);
await logEvent(connectionId, event, "success");
logMovieWatch(userId, title.id, provider);
logEvent(connectionId, event, "success");
return { status: "success", message: `Logged watch for ${event.title}` };
}
if (event.mediaType === "episode") {
const resolved = await resolveEpisode(event);
if (!resolved) {
await logEvent(
connectionId,
event,
"error",
"Could not resolve episode",
);
logEvent(connectionId, event, "error", "Could not resolve episode");
return { status: "error", message: "Could not resolve episode" };
}
const title = await importTitle(resolved.showTmdbId, "tv");
if (!title) {
await logEvent(
connectionId,
event,
"error",
"Failed to import TV show",
);
logEvent(connectionId, event, "error", "Failed to import TV show");
return { status: "error", message: "Failed to import TV show" };
}
// Find the episode in our DB
const season = await db
const season = db
.select()
.from(seasons)
.where(
@@ -341,7 +323,7 @@ export async function processWebhook(
.get();
if (!season) {
await logEvent(
logEvent(
connectionId,
event,
"error",
@@ -353,7 +335,7 @@ export async function processWebhook(
};
}
const episode = await db
const episode = db
.select()
.from(episodes)
.where(
@@ -365,7 +347,7 @@ export async function processWebhook(
.get();
if (!episode) {
await logEvent(
logEvent(
connectionId,
event,
"error",
@@ -377,8 +359,8 @@ export async function processWebhook(
};
}
if (await isDuplicateEpisodeWatch(userId, episode.id)) {
await logEvent(
if (isDuplicateEpisodeWatch(userId, episode.id)) {
logEvent(
connectionId,
event,
"ignored",
@@ -387,12 +369,12 @@ export async function processWebhook(
return { status: "ignored", message: "Duplicate watch" };
}
await logEpisodeWatch(userId, episode.id, provider);
await logEvent(connectionId, event, "success");
logEpisodeWatch(userId, episode.id, provider);
logEvent(connectionId, event, "success");
return { status: "success", message: `Logged watch for ${event.title}` };
}
await logEvent(
logEvent(
connectionId,
event,
"ignored",
@@ -401,7 +383,11 @@ export async function processWebhook(
return { status: "ignored", message: "Unsupported media type" };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
await logEvent(connectionId, event, "error", message).catch(() => {});
try {
logEvent(connectionId, event, "error", message);
} catch {
// best-effort
}
return { status: "error", message };
}
}