mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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>
357 lines
9.2 KiB
TypeScript
357 lines
9.2 KiB
TypeScript
import { and, desc, eq, sql } from "drizzle-orm";
|
|
import { db } from "@/lib/db/client";
|
|
import {
|
|
availabilityOffers,
|
|
episodes,
|
|
seasons,
|
|
titleRecommendations,
|
|
titles,
|
|
userEpisodeWatches,
|
|
userMovieWatches,
|
|
userRatings,
|
|
userTitleStatus,
|
|
} from "@/lib/db/schema";
|
|
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
|
|
|
export interface DashboardStats {
|
|
moviesThisMonth: number;
|
|
episodesThisWeek: number;
|
|
librarySize: number;
|
|
completed: number;
|
|
}
|
|
|
|
export function getUserStats(userId: string): DashboardStats {
|
|
const now = new Date();
|
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
const dayOfWeek = now.getDay();
|
|
const weekStart = new Date(now);
|
|
weekStart.setDate(now.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
|
|
weekStart.setHours(0, 0, 0, 0);
|
|
|
|
const [moviesThisMonth] = db
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(userMovieWatches)
|
|
.where(
|
|
and(
|
|
eq(userMovieWatches.userId, userId),
|
|
sql`${userMovieWatches.watchedAt} >= ${Math.floor(monthStart.getTime() / 1000)}`,
|
|
),
|
|
)
|
|
.all();
|
|
|
|
const [episodesThisWeek] = db
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(userEpisodeWatches)
|
|
.where(
|
|
and(
|
|
eq(userEpisodeWatches.userId, userId),
|
|
sql`${userEpisodeWatches.watchedAt} >= ${Math.floor(weekStart.getTime() / 1000)}`,
|
|
),
|
|
)
|
|
.all();
|
|
|
|
const [librarySizeRow] = db
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(userTitleStatus)
|
|
.where(eq(userTitleStatus.userId, userId))
|
|
.all();
|
|
|
|
const [completedCount] = db
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(userTitleStatus)
|
|
.where(
|
|
and(
|
|
eq(userTitleStatus.userId, userId),
|
|
eq(userTitleStatus.status, "completed"),
|
|
),
|
|
)
|
|
.all();
|
|
|
|
return {
|
|
moviesThisMonth: moviesThisMonth?.count ?? 0,
|
|
episodesThisWeek: episodesThisWeek?.count ?? 0,
|
|
librarySize: librarySizeRow?.count ?? 0,
|
|
completed: completedCount?.count ?? 0,
|
|
};
|
|
}
|
|
|
|
export interface ContinueWatchingItem {
|
|
title: {
|
|
id: string;
|
|
title: string;
|
|
posterPath: string | null;
|
|
backdropPath: string | null;
|
|
type: string;
|
|
};
|
|
nextEpisode: {
|
|
id: string;
|
|
seasonNumber: number;
|
|
episodeNumber: number;
|
|
name: string | null;
|
|
stillPath: string | null;
|
|
overview: string | null;
|
|
} | null;
|
|
lastWatchedAt: Date | null;
|
|
totalEpisodes: number;
|
|
watchedEpisodes: number;
|
|
}
|
|
|
|
export function getContinueWatchingFeed(
|
|
userId: string,
|
|
): ContinueWatchingItem[] {
|
|
// Get in-progress TV shows
|
|
const inProgress = db
|
|
.select({
|
|
titleId: userTitleStatus.titleId,
|
|
updatedAt: userTitleStatus.updatedAt,
|
|
})
|
|
.from(userTitleStatus)
|
|
.where(
|
|
and(
|
|
eq(userTitleStatus.userId, userId),
|
|
eq(userTitleStatus.status, "in_progress"),
|
|
),
|
|
)
|
|
.all();
|
|
|
|
const items: ContinueWatchingItem[] = [];
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
|
|
for (const row of inProgress) {
|
|
const title = db
|
|
.select()
|
|
.from(titles)
|
|
.where(and(eq(titles.id, row.titleId), eq(titles.type, "tv")))
|
|
.get();
|
|
if (!title) continue;
|
|
|
|
// Get all seasons for this title, ordered
|
|
const titleSeasons = db
|
|
.select()
|
|
.from(seasons)
|
|
.where(eq(seasons.titleId, title.id))
|
|
.orderBy(seasons.seasonNumber)
|
|
.all();
|
|
|
|
// Find first unwatched episode
|
|
let nextEpisode: ContinueWatchingItem["nextEpisode"] = null;
|
|
let lastWatchedAt: Date | null = null;
|
|
let totalEpisodes = 0;
|
|
let watchedEpisodes = 0;
|
|
|
|
// Get most recent watch for this show
|
|
for (const s of titleSeasons) {
|
|
const eps = db
|
|
.select()
|
|
.from(episodes)
|
|
.where(eq(episodes.seasonId, s.id))
|
|
.orderBy(episodes.episodeNumber)
|
|
.all();
|
|
|
|
totalEpisodes += eps.length;
|
|
|
|
for (const ep of eps) {
|
|
const watch = db
|
|
.select()
|
|
.from(userEpisodeWatches)
|
|
.where(
|
|
and(
|
|
eq(userEpisodeWatches.userId, userId),
|
|
eq(userEpisodeWatches.episodeId, ep.id),
|
|
),
|
|
)
|
|
.get();
|
|
|
|
if (watch) {
|
|
watchedEpisodes++;
|
|
if (!lastWatchedAt || watch.watchedAt > lastWatchedAt) {
|
|
lastWatchedAt = watch.watchedAt;
|
|
}
|
|
} else if (!nextEpisode) {
|
|
// Skip episodes not yet aired
|
|
if (ep.airDate && ep.airDate > today) continue;
|
|
nextEpisode = {
|
|
id: ep.id,
|
|
seasonNumber: s.seasonNumber,
|
|
episodeNumber: ep.episodeNumber,
|
|
name: ep.name,
|
|
stillPath: ep.stillPath,
|
|
overview: ep.overview,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
if (nextEpisode) {
|
|
items.push({
|
|
title: {
|
|
id: title.id,
|
|
title: title.title,
|
|
posterPath: title.posterPath,
|
|
backdropPath: title.backdropPath,
|
|
type: title.type,
|
|
},
|
|
nextEpisode,
|
|
lastWatchedAt,
|
|
totalEpisodes,
|
|
watchedEpisodes,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort by most recent watch
|
|
items.sort((a, b) => {
|
|
const aTime = a.lastWatchedAt?.getTime() ?? 0;
|
|
const bTime = b.lastWatchedAt?.getTime() ?? 0;
|
|
return bTime - aTime;
|
|
});
|
|
|
|
return items;
|
|
}
|
|
|
|
// biome-ignore lint/correctness/noUnusedFunctionParameters: days reserved for future date filtering
|
|
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 = db
|
|
.select({
|
|
titleId: titles.id,
|
|
title: titles.title,
|
|
type: titles.type,
|
|
tmdbId: titles.tmdbId,
|
|
posterPath: titles.posterPath,
|
|
releaseDate: titles.releaseDate,
|
|
firstAirDate: titles.firstAirDate,
|
|
voteAverage: titles.voteAverage,
|
|
popularity: titles.popularity,
|
|
})
|
|
.from(titles)
|
|
.innerJoin(
|
|
userTitleStatus,
|
|
and(
|
|
eq(userTitleStatus.titleId, titles.id),
|
|
eq(userTitleStatus.userId, userId),
|
|
),
|
|
)
|
|
.where(
|
|
sql`EXISTS (SELECT 1 FROM ${availabilityOffers} WHERE ${availabilityOffers.titleId} = ${titles.id})`,
|
|
)
|
|
.orderBy(desc(titles.popularity))
|
|
.limit(20)
|
|
.all();
|
|
|
|
return results;
|
|
}
|
|
|
|
export function getRecommendationsFeed(userId: string) {
|
|
// Get recommendations from user's highly-rated or completed titles
|
|
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 = 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(
|
|
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 = db
|
|
.select({
|
|
recommendedTitleId: titleRecommendations.recommendedTitleId,
|
|
rank: titleRecommendations.rank,
|
|
})
|
|
.from(titleRecommendations)
|
|
.where(eq(titleRecommendations.titleId, sourceId))
|
|
.all();
|
|
|
|
for (const rec of recRows) {
|
|
if (trackedIds.has(rec.recommendedTitleId)) continue;
|
|
const existing = recs.get(rec.recommendedTitleId);
|
|
const score = 100 - rec.rank;
|
|
if (existing) {
|
|
existing.score += score;
|
|
} else {
|
|
recs.set(rec.recommendedTitleId, {
|
|
titleId: rec.recommendedTitleId,
|
|
score,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const sorted = [...recs.values()]
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, 20);
|
|
|
|
return sorted
|
|
.map((r) => db.select().from(titles).where(eq(titles.id, r.titleId)).get())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
export function getRecommendationsForTitle(titleId: string) {
|
|
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
|
if (!title) return [];
|
|
|
|
const recs = db
|
|
.select({
|
|
recommendedTitleId: titleRecommendations.recommendedTitleId,
|
|
source: titleRecommendations.source,
|
|
rank: titleRecommendations.rank,
|
|
})
|
|
.from(titleRecommendations)
|
|
.where(eq(titleRecommendations.titleId, titleId))
|
|
.orderBy(titleRecommendations.rank)
|
|
.all();
|
|
|
|
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,
|
|
tmdbId: r.tmdbId,
|
|
type: r.type as "movie" | "tv",
|
|
title: r.title,
|
|
posterPath: tmdbImageUrl(r.posterPath, "w500"),
|
|
releaseDate: r.releaseDate,
|
|
firstAirDate: r.firstAirDate,
|
|
voteAverage: r.voteAverage,
|
|
}));
|
|
}
|