mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 06:25:42 -04:00
feat: add filtering and sorting across library, explore, and upcoming (#21)
* feat: add filtering and sorting across library, explore, and upcoming Add comprehensive filtering and sorting to all list views on both web and mobile. Library (new dedicated page): - New /library route (web) with URL search param persistence - New Library tab (5th tab, mobile) with FlashList grid - Filters: status (multi-select), type, genre (library-only), user rating range, release year (decade presets + custom), content rating, available to stream - Sort by: title, date added, release date, popularity, user rating, TMDB rating - Text search within collection - Filter popover (web) / bottom sheet with Apply (mobile) - Remove old dashboard.library endpoint in favor of library.list Explore/Discover: - New Discover section on Explore page with inline filter controls - TMDB filters: genre (now optional), year range, min rating, sort order, original language, streaming provider - Watch provider list endpoint (WATCH_REGION env var, default "US") - Default to popular content when no filters selected Upcoming: - Type filter (All/Movies/TV Shows) and status filter (All/Watching/Watchlist) - Toggle chips on both web (URL params) and mobile - Backend: mediaType and statusFilter params on upcoming endpoint Navigation: - Library added to web nav bar (desktop + mobile tab bar) - Library added as 5th tab on mobile (between Home and Explore) - Dashboard library section simplified to compact preview with "See all" link Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add user streaming platform subscriptions and redesign filter UI Replace the denormalized availabilityOffers table with a normalized platforms + titleAvailability + userPlatforms schema. Users can now declare which streaming services they subscribe to, enabling personalized "On your services" availability display and filtered library/explore results. Backend: new platforms table seeded from TMDB provider data on startup, new API endpoints (platforms.list, account.platforms, account.updatePlatforms), title detail annotates availability with isUserSubscribed flag, library "on my services" filter checks user's subscribed platforms. Web UI: post-registration onboarding page for platform selection, streaming services settings section, title detail split into "On your services" / "Also available on", explore dropdown uses platforms table with active filter highlighting. Library filters redesigned from cluttered popover to clean collapsible inline strip with consolidated dropdowns. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address race conditions, stale state, and edge cases in platforms feature - Fix debounced autosave race in streaming services settings: use ref for latest selection + counter guard so only the most recent save updates UI. Clean up timers on unmount. - Fix explore provider dropdown desyncing from query filter when switching Movie/TV type: reset selectedPlatformId alongside providerId. - Fix platformIdsExist rejecting duplicate valid IDs by deduplicating input before comparing against DB results. - Fix stale platform metadata: always upsert name/logo on TMDB refresh instead of skipping when platform already exists. - Fix invisible ratingMax filter: clear ratingMax when user changes ratingMin since the UI only exposes a single rating dropdown now. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: clear stale saved-state timer and preserve falsy filter values - Clear previous savedTimerRef timeout before scheduling a new one in streaming-services-section, preventing an older timer from hiding the "Saved" indicator too early on rapid successive saves - Replace `value || undefined` with explicit empty-value check in library filter handler so numeric 0 (e.g. ratingMin=0) is not incorrectly dropped Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add TanStack Form and migrate auth + change password forms Set up TanStack Form v1 composition layer with createFormHook, pre-bound field components (TextField, TextareaField, CheckboxField, SwitchField, SubmitButton), and migrate the two traditional submit-based forms: - AuthForm: replace 4 useState calls with useAppForm, use form.Field for each input while preserving motion animation layout - ChangePasswordDialog: replace 6 useState calls with useAppForm + Zod schema validation (cross-field password match, min length), per-field error display, form.reset() on dialog close Also adds @tanstack/react-form to the monorepo catalog and updates both web and native apps to reference it via catalog:. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d1b0d952bd
commit
4fc6b2f8d1
76 files changed
+8918
-942
No files matched your search
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE `platforms` (
|
||||
`id` text PRIMARY KEY,
|
||||
`name` text NOT NULL,
|
||||
`tmdbProviderId` integer,
|
||||
`logoPath` text,
|
||||
`urlTemplate` text,
|
||||
`displayOrder` integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `titleAvailability` (
|
||||
`titleId` text NOT NULL,
|
||||
`platformId` text NOT NULL,
|
||||
`offerType` text NOT NULL,
|
||||
`region` text DEFAULT 'US' NOT NULL,
|
||||
`lastFetchedAt` integer,
|
||||
CONSTRAINT `fk_titleAvailability_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_titleAvailability_platformId_platforms_id_fk` FOREIGN KEY (`platformId`) REFERENCES `platforms`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `userPlatforms` (
|
||||
`userId` text NOT NULL,
|
||||
`platformId` text NOT NULL,
|
||||
CONSTRAINT `fk_userPlatforms_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_userPlatforms_platformId_platforms_id_fk` FOREIGN KEY (`platformId`) REFERENCES `platforms`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `availabilityOffers_unique`;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `platforms_tmdbProviderId_unique` ON `platforms` (`tmdbProviderId`);--> statement-breakpoint
|
||||
CREATE INDEX `platforms_displayOrder` ON `platforms` (`displayOrder`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `titleAvailability_unique` ON `titleAvailability` (`titleId`,`platformId`,`offerType`,`region`);--> statement-breakpoint
|
||||
CREATE INDEX `titleAvailability_titleId` ON `titleAvailability` (`titleId`);--> statement-breakpoint
|
||||
CREATE INDEX `titleAvailability_platformId` ON `titleAvailability` (`platformId`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `userPlatforms_userId_platformId` ON `userPlatforms` (`userId`,`platformId`);--> statement-breakpoint
|
||||
CREATE INDEX `userPlatforms_userId` ON `userPlatforms` (`userId`);--> statement-breakpoint
|
||||
DROP TABLE `availabilityOffers`;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -7,7 +7,8 @@
|
||||
"./client": "./src/client.ts",
|
||||
"./migrate": "./src/migrate.ts",
|
||||
"./schema": "./src/schema.ts",
|
||||
"./queries/*": "./src/queries/*.ts"
|
||||
"./queries/*": "./src/queries/*.ts",
|
||||
"./seed-platforms": "./src/seed-platforms.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "oxlint",
|
||||
|
||||
@@ -1,24 +1,66 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import { availabilityOffers } from "../schema";
|
||||
import { platforms, titleAvailability } from "../schema";
|
||||
|
||||
export function replaceAvailabilityTransaction(
|
||||
titleId: string,
|
||||
region: string,
|
||||
offers: (typeof availabilityOffers.$inferInsert)[],
|
||||
offers: (typeof titleAvailability.$inferInsert)[],
|
||||
): void {
|
||||
db.transaction((tx) => {
|
||||
tx.delete(availabilityOffers)
|
||||
.where(and(eq(availabilityOffers.titleId, titleId), eq(availabilityOffers.region, region)))
|
||||
tx.delete(titleAvailability)
|
||||
.where(and(eq(titleAvailability.titleId, titleId), eq(titleAvailability.region, region)))
|
||||
.run();
|
||||
|
||||
if (offers.length > 0) {
|
||||
tx.insert(availabilityOffers).values(offers).onConflictDoNothing().run();
|
||||
tx.insert(titleAvailability).values(offers).onConflictDoNothing().run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getAvailabilityOffers(titleId: string) {
|
||||
return db.select().from(availabilityOffers).where(eq(availabilityOffers.titleId, titleId)).all();
|
||||
export function getAvailabilityForTitle(titleId: string) {
|
||||
return db
|
||||
.select({
|
||||
platformId: platforms.id,
|
||||
providerName: platforms.name,
|
||||
logoPath: platforms.logoPath,
|
||||
urlTemplate: platforms.urlTemplate,
|
||||
tmdbProviderId: platforms.tmdbProviderId,
|
||||
offerType: titleAvailability.offerType,
|
||||
})
|
||||
.from(titleAvailability)
|
||||
.innerJoin(platforms, eq(titleAvailability.platformId, platforms.id))
|
||||
.where(eq(titleAvailability.titleId, titleId))
|
||||
.all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a platform row exists for a TMDB provider. Upserts by tmdbProviderId.
|
||||
* Returns the platform ID.
|
||||
*/
|
||||
export function ensurePlatformForTmdbProvider(
|
||||
tmdbProviderId: number,
|
||||
name: string,
|
||||
logoPath: string | null,
|
||||
): string {
|
||||
const row = db
|
||||
.insert(platforms)
|
||||
.values({
|
||||
tmdbProviderId,
|
||||
name,
|
||||
logoPath,
|
||||
displayOrder: 999,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: platforms.tmdbProviderId,
|
||||
set: {
|
||||
name: sql`excluded.name`,
|
||||
logoPath: sql`excluded.logoPath`,
|
||||
},
|
||||
})
|
||||
.returning({ id: platforms.id })
|
||||
.get();
|
||||
|
||||
return row!.id;
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { and, eq, gte, inArray, isNotNull, lt, or } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import {
|
||||
availabilityOffers,
|
||||
cronRuns,
|
||||
seasons,
|
||||
titleAvailability,
|
||||
titleCast,
|
||||
titleRecommendations,
|
||||
titles,
|
||||
@@ -64,10 +64,10 @@ export function getTitlesWithStaleOffers(titleIds: string[]) {
|
||||
if (titleIds.length === 0) return new Set<string>();
|
||||
return new Set(
|
||||
db
|
||||
.select({ titleId: availabilityOffers.titleId })
|
||||
.from(availabilityOffers)
|
||||
.where(inArray(availabilityOffers.titleId, titleIds))
|
||||
.groupBy(availabilityOffers.titleId)
|
||||
.select({ titleId: titleAvailability.titleId })
|
||||
.from(titleAvailability)
|
||||
.where(inArray(titleAvailability.titleId, titleIds))
|
||||
.groupBy(titleAvailability.titleId)
|
||||
.all()
|
||||
.map((r) => r.titleId),
|
||||
);
|
||||
@@ -77,15 +77,15 @@ export function getTitlesWithStaleOffersFetchedBefore(titleIds: string[], staleD
|
||||
if (titleIds.length === 0) return new Set<string>();
|
||||
return new Set(
|
||||
db
|
||||
.select({ titleId: availabilityOffers.titleId })
|
||||
.from(availabilityOffers)
|
||||
.select({ titleId: titleAvailability.titleId })
|
||||
.from(titleAvailability)
|
||||
.where(
|
||||
and(
|
||||
inArray(availabilityOffers.titleId, titleIds),
|
||||
lt(availabilityOffers.lastFetchedAt, staleDate),
|
||||
inArray(titleAvailability.titleId, titleIds),
|
||||
lt(titleAvailability.lastFetchedAt, staleDate),
|
||||
),
|
||||
)
|
||||
.groupBy(availabilityOffers.titleId)
|
||||
.groupBy(titleAvailability.titleId)
|
||||
.all()
|
||||
.map((r) => r.titleId),
|
||||
);
|
||||
|
||||
@@ -2,9 +2,10 @@ import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import {
|
||||
availabilityOffers,
|
||||
episodes,
|
||||
platforms,
|
||||
seasons,
|
||||
titleAvailability,
|
||||
titleRecommendations,
|
||||
titles,
|
||||
userEpisodeWatches,
|
||||
@@ -189,64 +190,13 @@ export function getNewAvailableFeed(userId: string, _days = 14) {
|
||||
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
|
||||
)
|
||||
.where(
|
||||
sql`EXISTS (SELECT 1 FROM ${availabilityOffers} WHERE ${availabilityOffers.titleId} = ${titles.id})`,
|
||||
sql`EXISTS (SELECT 1 FROM ${titleAvailability} WHERE ${titleAvailability.titleId} = ${titles.id})`,
|
||||
)
|
||||
.orderBy(desc(titles.popularity))
|
||||
.limit(20)
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getLibraryFeed(userId: string, page = 1, limit = 20) {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const availabilityFilter = sql`EXISTS (SELECT 1 FROM ${availabilityOffers} WHERE ${availabilityOffers.titleId} = ${titles.id})`;
|
||||
const joinCondition = and(
|
||||
eq(userTitleStatus.titleId, titles.id),
|
||||
eq(userTitleStatus.userId, userId),
|
||||
);
|
||||
|
||||
const rows = db
|
||||
.select({
|
||||
titleId: titles.id,
|
||||
title: titles.title,
|
||||
type: titles.type,
|
||||
tmdbId: titles.tmdbId,
|
||||
posterPath: titles.posterPath,
|
||||
posterThumbHash: titles.posterThumbHash,
|
||||
releaseDate: titles.releaseDate,
|
||||
firstAirDate: titles.firstAirDate,
|
||||
voteAverage: titles.voteAverage,
|
||||
popularity: titles.popularity,
|
||||
userStatus: userTitleStatus.status,
|
||||
totalCount: sql<number>`count(*) over()`.as("totalCount"),
|
||||
})
|
||||
.from(titles)
|
||||
.innerJoin(userTitleStatus, joinCondition)
|
||||
.where(availabilityFilter)
|
||||
.orderBy(desc(titles.popularity))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
|
||||
let totalResults = rows[0]?.totalCount ?? 0;
|
||||
if (rows.length === 0 && offset > 0) {
|
||||
const [{ count }] = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(titles)
|
||||
.innerJoin(userTitleStatus, joinCondition)
|
||||
.where(availabilityFilter)
|
||||
.all();
|
||||
totalResults = count ?? 0;
|
||||
}
|
||||
const items = rows.map(({ totalCount: _, ...item }) => item);
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
totalPages: Math.max(1, Math.ceil(totalResults / limit)),
|
||||
totalResults,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEngagedTitleIds(userId: string) {
|
||||
return db
|
||||
.select({ titleId: userTitleStatus.titleId })
|
||||
@@ -315,7 +265,22 @@ export function getTitleByIdOrNull(titleId: string) {
|
||||
|
||||
// ─── Upcoming feed queries ──────────────────────────────────────────
|
||||
|
||||
export function getUpcomingEpisodes(userId: string, fromDate: string, toDate: string) {
|
||||
export function getUpcomingEpisodes(
|
||||
userId: string,
|
||||
fromDate: string,
|
||||
toDate: string,
|
||||
statusFilter?: string[],
|
||||
) {
|
||||
const conditions = [gte(episodes.airDate, fromDate), lte(episodes.airDate, toDate)];
|
||||
if (statusFilter && statusFilter.length > 0) {
|
||||
conditions.push(
|
||||
sql`${userTitleStatus.status} IN (${sql.join(
|
||||
statusFilter.map((s) => sql`${s}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
}
|
||||
|
||||
return db
|
||||
.select({
|
||||
episodeId: episodes.id,
|
||||
@@ -338,12 +303,31 @@ export function getUpcomingEpisodes(userId: string, fromDate: string, toDate: st
|
||||
userTitleStatus,
|
||||
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
|
||||
)
|
||||
.where(and(gte(episodes.airDate, fromDate), lte(episodes.airDate, toDate)))
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(episodes.airDate), asc(titles.title))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getUpcomingMovies(userId: string, fromDate: string, toDate: string) {
|
||||
export function getUpcomingMovies(
|
||||
userId: string,
|
||||
fromDate: string,
|
||||
toDate: string,
|
||||
statusFilter?: string[],
|
||||
) {
|
||||
const conditions = [
|
||||
eq(titles.type, "movie"),
|
||||
gte(titles.releaseDate, fromDate),
|
||||
lte(titles.releaseDate, toDate),
|
||||
];
|
||||
if (statusFilter && statusFilter.length > 0) {
|
||||
conditions.push(
|
||||
sql`${userTitleStatus.status} IN (${sql.join(
|
||||
statusFilter.map((s) => sql`${s}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
}
|
||||
|
||||
return db
|
||||
.select({
|
||||
titleId: titles.id,
|
||||
@@ -360,13 +344,7 @@ export function getUpcomingMovies(userId: string, fromDate: string, toDate: stri
|
||||
userTitleStatus,
|
||||
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(titles.type, "movie"),
|
||||
gte(titles.releaseDate, fromDate),
|
||||
lte(titles.releaseDate, toDate),
|
||||
),
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(titles.releaseDate), asc(titles.title))
|
||||
.all();
|
||||
}
|
||||
@@ -375,16 +353,17 @@ export function getAvailabilityByTitleIds(titleIds: string[]) {
|
||||
if (titleIds.length === 0) return [];
|
||||
return db
|
||||
.select({
|
||||
titleId: availabilityOffers.titleId,
|
||||
providerId: availabilityOffers.providerId,
|
||||
providerName: availabilityOffers.providerName,
|
||||
logoPath: availabilityOffers.logoPath,
|
||||
titleId: titleAvailability.titleId,
|
||||
platformId: platforms.id,
|
||||
providerName: platforms.name,
|
||||
logoPath: platforms.logoPath,
|
||||
})
|
||||
.from(availabilityOffers)
|
||||
.from(titleAvailability)
|
||||
.innerJoin(platforms, eq(titleAvailability.platformId, platforms.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(availabilityOffers.titleId, titleIds),
|
||||
eq(availabilityOffers.offerType, "flatrate"),
|
||||
inArray(titleAvailability.titleId, titleIds),
|
||||
eq(titleAvailability.offerType, "flatrate"),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import { availabilityOffers, episodes, persons, seasons, titleCast, titles } from "../schema";
|
||||
import {
|
||||
episodes,
|
||||
persons,
|
||||
platforms,
|
||||
seasons,
|
||||
titleAvailability,
|
||||
titleCast,
|
||||
titles,
|
||||
} from "../schema";
|
||||
|
||||
// ─── Title image paths ───────────────────────────────────────────────
|
||||
|
||||
@@ -51,9 +59,10 @@ export function getEpisodeStillsForTitle(titleId: string) {
|
||||
|
||||
export function getAvailabilityLogosForTitle(titleId: string) {
|
||||
return db
|
||||
.select({ logoPath: availabilityOffers.logoPath })
|
||||
.from(availabilityOffers)
|
||||
.where(eq(availabilityOffers.titleId, titleId))
|
||||
.select({ logoPath: platforms.logoPath })
|
||||
.from(titleAvailability)
|
||||
.innerJoin(platforms, eq(titleAvailability.platformId, platforms.id))
|
||||
.where(eq(titleAvailability.titleId, titleId))
|
||||
.all();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { and, asc, countDistinct, desc, eq, gte, isNotNull, lte, sql } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import {
|
||||
episodes,
|
||||
genres,
|
||||
seasons,
|
||||
titleAvailability,
|
||||
titleGenres,
|
||||
titles,
|
||||
userEpisodeWatches,
|
||||
userPlatforms,
|
||||
userRatings,
|
||||
userTitleStatus,
|
||||
} from "../schema";
|
||||
|
||||
// ─── Display status SQL ─────────────────────────────────────────────
|
||||
// Derives the user-facing display status inline for filtering/output.
|
||||
// Mirrors the logic in @sofa/core/display-status.ts.
|
||||
// Drizzle has no CASE/WHEN builder, but subqueries built with db.select()
|
||||
// can be embedded in sql`` templates for type-safe column references.
|
||||
|
||||
function airedEpisodeCount(titleId: typeof titles.id, today: string) {
|
||||
return db
|
||||
.select({ count: countDistinct(episodes.id) })
|
||||
.from(episodes)
|
||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||
.where(
|
||||
and(eq(seasons.titleId, titleId), isNotNull(episodes.airDate), lte(episodes.airDate, today)),
|
||||
);
|
||||
}
|
||||
|
||||
function watchedEpisodeCount(
|
||||
titleId: typeof titles.id,
|
||||
userId: typeof userTitleStatus.userId,
|
||||
today: string,
|
||||
) {
|
||||
return db
|
||||
.select({ count: countDistinct(userEpisodeWatches.episodeId) })
|
||||
.from(userEpisodeWatches)
|
||||
.innerJoin(episodes, eq(userEpisodeWatches.episodeId, episodes.id))
|
||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||
.where(
|
||||
and(
|
||||
eq(seasons.titleId, titleId),
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
isNotNull(episodes.airDate),
|
||||
lte(episodes.airDate, today),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function displayStatusExpr() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const aired = airedEpisodeCount(titles.id, today);
|
||||
const watched = watchedEpisodeCount(titles.id, userTitleStatus.userId, today);
|
||||
|
||||
// Order must match @sofa/core/display-status.ts:
|
||||
// 1. watchlist → in_watchlist (any type)
|
||||
// 2. movie → completed if stored=completed, else in_watchlist
|
||||
// 3. TV completed → completed
|
||||
// 4. TV in_progress → check episode progress
|
||||
return sql<string>`(
|
||||
CASE
|
||||
WHEN ${userTitleStatus.status} = 'watchlist' THEN 'in_watchlist'
|
||||
WHEN ${titles.type} = 'movie' THEN
|
||||
CASE WHEN ${userTitleStatus.status} = 'completed' THEN 'completed' ELSE 'in_watchlist' END
|
||||
WHEN ${userTitleStatus.status} = 'completed' THEN 'completed'
|
||||
WHEN (${aired}) > 0 AND (${aired}) = (${watched})
|
||||
THEN CASE
|
||||
WHEN ${titles.status} IN ('Returning Series', 'In Production') THEN 'caught_up'
|
||||
ELSE 'completed'
|
||||
END
|
||||
ELSE 'watching'
|
||||
END
|
||||
)`;
|
||||
}
|
||||
|
||||
// ─── Filtered library feed ──────────────────────────────────────────
|
||||
|
||||
export interface LibraryFilters {
|
||||
search?: string;
|
||||
statuses?: string[];
|
||||
type?: "movie" | "tv";
|
||||
genreId?: number;
|
||||
ratingMin?: number;
|
||||
ratingMax?: number;
|
||||
yearMin?: number;
|
||||
yearMax?: number;
|
||||
contentRating?: string;
|
||||
onMyServices?: boolean;
|
||||
sortBy: string;
|
||||
sortDirection: "asc" | "desc";
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export function getFilteredLibrary(userId: string, filters: LibraryFilters) {
|
||||
const offset = (filters.page - 1) * filters.limit;
|
||||
// Only "in_watchlist" maps 1:1 to a stored status. The others (watching, caught_up, completed)
|
||||
// all involve derived logic from episode progress / TMDB show status, so we need the full
|
||||
// display-status SQL computation to filter them correctly.
|
||||
const needsDisplayStatus =
|
||||
filters.statuses &&
|
||||
filters.statuses.some((s) => s === "watching" || s === "caught_up" || s === "completed");
|
||||
|
||||
// Build WHERE conditions
|
||||
const conditions = [eq(userTitleStatus.userId, userId)];
|
||||
|
||||
if (filters.search) {
|
||||
conditions.push(sql`${titles.title} LIKE ${"%" + filters.search + "%"}`);
|
||||
}
|
||||
|
||||
if (filters.type) {
|
||||
conditions.push(eq(titles.type, filters.type));
|
||||
}
|
||||
|
||||
if (filters.genreId) {
|
||||
conditions.push(
|
||||
sql`EXISTS (SELECT 1 FROM ${titleGenres} WHERE ${titleGenres.titleId} = ${titles.id} AND ${titleGenres.genreId} = ${filters.genreId})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.ratingMin != null || filters.ratingMax != null) {
|
||||
conditions.push(sql`${userRatings.ratingStars} IS NOT NULL`);
|
||||
if (filters.ratingMin != null) {
|
||||
conditions.push(gte(userRatings.ratingStars, filters.ratingMin));
|
||||
}
|
||||
if (filters.ratingMax != null) {
|
||||
conditions.push(lte(userRatings.ratingStars, filters.ratingMax));
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.yearMin != null || filters.yearMax != null) {
|
||||
const yearExpr = sql`CAST(strftime('%Y', COALESCE(${titles.releaseDate}, ${titles.firstAirDate})) AS INTEGER)`;
|
||||
if (filters.yearMin != null) {
|
||||
conditions.push(sql`${yearExpr} >= ${filters.yearMin}`);
|
||||
}
|
||||
if (filters.yearMax != null) {
|
||||
conditions.push(sql`${yearExpr} <= ${filters.yearMax}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.contentRating) {
|
||||
conditions.push(eq(titles.contentRating, filters.contentRating));
|
||||
}
|
||||
|
||||
if (filters.onMyServices) {
|
||||
// Check if user has platforms set; if so, filter to titles available on their services
|
||||
const userHasPlatforms =
|
||||
db
|
||||
.select({ platformId: userPlatforms.platformId })
|
||||
.from(userPlatforms)
|
||||
.where(eq(userPlatforms.userId, userId))
|
||||
.limit(1)
|
||||
.get() != null;
|
||||
|
||||
if (userHasPlatforms) {
|
||||
conditions.push(
|
||||
sql`EXISTS (
|
||||
SELECT 1 FROM ${titleAvailability} ta
|
||||
JOIN ${userPlatforms} up ON ta.platformId = up.platformId
|
||||
WHERE ta.titleId = ${titles.id} AND up.userId = ${userId}
|
||||
)`,
|
||||
);
|
||||
} else {
|
||||
// Fallback: any availability
|
||||
conditions.push(
|
||||
sql`EXISTS (SELECT 1 FROM ${titleAvailability} WHERE ${titleAvailability.titleId} = ${titles.id})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Status filtering: optimize simple cases
|
||||
if (filters.statuses && filters.statuses.length > 0 && !needsDisplayStatus) {
|
||||
// Map display statuses to stored statuses for simple cases
|
||||
const storedStatuses: string[] = [];
|
||||
for (const s of filters.statuses) {
|
||||
if (s === "in_watchlist") storedStatuses.push("watchlist");
|
||||
if (s === "completed") storedStatuses.push("completed");
|
||||
}
|
||||
if (storedStatuses.length === 1) {
|
||||
conditions.push(eq(userTitleStatus.status, storedStatuses[0] as "watchlist" | "completed"));
|
||||
} else if (storedStatuses.length > 1) {
|
||||
conditions.push(
|
||||
sql`${userTitleStatus.status} IN (${sql.join(
|
||||
storedStatuses.map((s) => sql`${s}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort expression
|
||||
const dirFn = filters.sortDirection === "asc" ? asc : desc;
|
||||
const sortExpressions = [];
|
||||
|
||||
switch (filters.sortBy) {
|
||||
case "title":
|
||||
sortExpressions.push(dirFn(titles.title));
|
||||
break;
|
||||
case "added_at":
|
||||
sortExpressions.push(dirFn(userTitleStatus.addedAt));
|
||||
break;
|
||||
case "release_date":
|
||||
sortExpressions.push(dirFn(sql`COALESCE(${titles.releaseDate}, ${titles.firstAirDate})`));
|
||||
break;
|
||||
case "popularity":
|
||||
sortExpressions.push(dirFn(titles.popularity));
|
||||
break;
|
||||
case "user_rating":
|
||||
// NULLS LAST: put unrated items at the end
|
||||
sortExpressions.push(
|
||||
asc(sql`CASE WHEN ${userRatings.ratingStars} IS NULL THEN 1 ELSE 0 END`),
|
||||
);
|
||||
sortExpressions.push(dirFn(userRatings.ratingStars));
|
||||
break;
|
||||
case "vote_average":
|
||||
sortExpressions.push(dirFn(titles.voteAverage));
|
||||
break;
|
||||
default:
|
||||
sortExpressions.push(desc(userTitleStatus.addedAt));
|
||||
}
|
||||
|
||||
// Build query with display status when needed for filtering
|
||||
if (needsDisplayStatus) {
|
||||
const rows = db
|
||||
.select({
|
||||
titleId: titles.id,
|
||||
title: titles.title,
|
||||
type: titles.type,
|
||||
tmdbId: titles.tmdbId,
|
||||
posterPath: titles.posterPath,
|
||||
posterThumbHash: titles.posterThumbHash,
|
||||
releaseDate: titles.releaseDate,
|
||||
firstAirDate: titles.firstAirDate,
|
||||
voteAverage: titles.voteAverage,
|
||||
popularity: titles.popularity,
|
||||
userStatus: userTitleStatus.status,
|
||||
userRating: userRatings.ratingStars,
|
||||
displayStatus: displayStatusExpr().as("display_status"),
|
||||
})
|
||||
.from(titles)
|
||||
.innerJoin(
|
||||
userTitleStatus,
|
||||
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
|
||||
)
|
||||
.leftJoin(
|
||||
userRatings,
|
||||
and(eq(userRatings.titleId, titles.id), eq(userRatings.userId, userId)),
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.orderBy(...sortExpressions)
|
||||
.all();
|
||||
|
||||
// Post-filter by display status
|
||||
const filtered = rows.filter((r) => filters.statuses!.includes(r.displayStatus));
|
||||
const totalResults = filtered.length;
|
||||
const paged = filtered.slice(offset, offset + filters.limit);
|
||||
|
||||
return {
|
||||
items: paged.map((row) => {
|
||||
const { displayStatus, ...item } = row;
|
||||
return Object.assign(item, { displayStatus });
|
||||
}),
|
||||
page: filters.page,
|
||||
totalPages: Math.max(1, Math.ceil(totalResults / filters.limit)),
|
||||
totalResults,
|
||||
};
|
||||
}
|
||||
|
||||
// Standard query (no display status computation needed)
|
||||
const rows = db
|
||||
.select({
|
||||
titleId: titles.id,
|
||||
title: titles.title,
|
||||
type: titles.type,
|
||||
tmdbId: titles.tmdbId,
|
||||
posterPath: titles.posterPath,
|
||||
posterThumbHash: titles.posterThumbHash,
|
||||
releaseDate: titles.releaseDate,
|
||||
firstAirDate: titles.firstAirDate,
|
||||
voteAverage: titles.voteAverage,
|
||||
popularity: titles.popularity,
|
||||
userStatus: userTitleStatus.status,
|
||||
userRating: userRatings.ratingStars,
|
||||
totalCount: sql<number>`count(*) over()`.as("totalCount"),
|
||||
})
|
||||
.from(titles)
|
||||
.innerJoin(
|
||||
userTitleStatus,
|
||||
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
|
||||
)
|
||||
.leftJoin(userRatings, and(eq(userRatings.titleId, titles.id), eq(userRatings.userId, userId)))
|
||||
.where(and(...conditions))
|
||||
.orderBy(...sortExpressions)
|
||||
.limit(filters.limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
|
||||
const totalResults = rows[0]?.totalCount ?? 0;
|
||||
const items = rows.map(({ totalCount: _, ...item }) => item);
|
||||
|
||||
return {
|
||||
items,
|
||||
page: filters.page,
|
||||
totalPages: Math.max(1, Math.ceil(totalResults / filters.limit)),
|
||||
totalResults,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Library genres ─────────────────────────────────────────────────
|
||||
|
||||
export function getLibraryGenres(userId: string) {
|
||||
return db
|
||||
.select({
|
||||
id: genres.id,
|
||||
name: genres.name,
|
||||
})
|
||||
.from(genres)
|
||||
.innerJoin(titleGenres, eq(titleGenres.genreId, genres.id))
|
||||
.innerJoin(
|
||||
userTitleStatus,
|
||||
and(eq(userTitleStatus.titleId, titleGenres.titleId), eq(userTitleStatus.userId, userId)),
|
||||
)
|
||||
.groupBy(genres.id, genres.name)
|
||||
.orderBy(asc(genres.name))
|
||||
.all();
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import {
|
||||
availabilityOffers,
|
||||
episodes,
|
||||
genres,
|
||||
platforms,
|
||||
seasons,
|
||||
titleAvailability,
|
||||
titleGenres,
|
||||
titleRecommendations,
|
||||
titles,
|
||||
@@ -260,7 +261,19 @@ export function getEpisodesNeedingStillHash(seasonIds: string[]) {
|
||||
// ─── Availability ────────────────────────────────────────────────────
|
||||
|
||||
export function getAvailabilityOffersForTitle(titleId: string) {
|
||||
return db.select().from(availabilityOffers).where(eq(availabilityOffers.titleId, titleId)).all();
|
||||
return db
|
||||
.select({
|
||||
platformId: platforms.id,
|
||||
providerName: platforms.name,
|
||||
logoPath: platforms.logoPath,
|
||||
urlTemplate: platforms.urlTemplate,
|
||||
tmdbProviderId: platforms.tmdbProviderId,
|
||||
offerType: titleAvailability.offerType,
|
||||
})
|
||||
.from(titleAvailability)
|
||||
.innerJoin(platforms, eq(titleAvailability.platformId, platforms.id))
|
||||
.where(eq(titleAvailability.titleId, titleId))
|
||||
.all();
|
||||
}
|
||||
|
||||
// ─── Recommendations ─────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { db } from "../client";
|
||||
import { platforms, userPlatforms } from "../schema";
|
||||
|
||||
export function getUserPlatformIds(userId: string): string[] {
|
||||
return db
|
||||
.select({ platformId: userPlatforms.platformId })
|
||||
.from(userPlatforms)
|
||||
.where(eq(userPlatforms.userId, userId))
|
||||
.all()
|
||||
.map((r) => r.platformId);
|
||||
}
|
||||
|
||||
export function getUserPlatforms(userId: string) {
|
||||
return db
|
||||
.select({
|
||||
id: platforms.id,
|
||||
name: platforms.name,
|
||||
tmdbProviderId: platforms.tmdbProviderId,
|
||||
logoPath: platforms.logoPath,
|
||||
urlTemplate: platforms.urlTemplate,
|
||||
displayOrder: platforms.displayOrder,
|
||||
})
|
||||
.from(userPlatforms)
|
||||
.innerJoin(platforms, eq(userPlatforms.platformId, platforms.id))
|
||||
.where(eq(userPlatforms.userId, userId))
|
||||
.orderBy(platforms.displayOrder)
|
||||
.all();
|
||||
}
|
||||
|
||||
export function setUserPlatforms(userId: string, platformIds: string[]): void {
|
||||
db.transaction((tx) => {
|
||||
tx.delete(userPlatforms).where(eq(userPlatforms.userId, userId)).run();
|
||||
if (platformIds.length > 0) {
|
||||
tx.insert(userPlatforms)
|
||||
.values(platformIds.map((platformId) => ({ userId, platformId })))
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getAllPlatforms() {
|
||||
return db.select().from(platforms).orderBy(platforms.displayOrder).all();
|
||||
}
|
||||
|
||||
export function hasUserPlatforms(userId: string): boolean {
|
||||
return (
|
||||
db
|
||||
.select({ platformId: userPlatforms.platformId })
|
||||
.from(userPlatforms)
|
||||
.where(eq(userPlatforms.userId, userId))
|
||||
.limit(1)
|
||||
.get() != null
|
||||
);
|
||||
}
|
||||
|
||||
export function platformIdsExist(platformIds: string[]): boolean {
|
||||
if (platformIds.length === 0) return true;
|
||||
const unique = [...new Set(platformIds)];
|
||||
const found = db
|
||||
.select({ id: platforms.id })
|
||||
.from(platforms)
|
||||
.where(inArray(platforms.id, unique))
|
||||
.all();
|
||||
return found.length === unique.length;
|
||||
}
|
||||
+45
-10
@@ -249,29 +249,64 @@ export const userRatings = sqliteTable(
|
||||
(table) => [uniqueIndex("userRatings_userId_titleId").on(table.userId, table.titleId)],
|
||||
);
|
||||
|
||||
export const availabilityOffers = sqliteTable(
|
||||
"availabilityOffers",
|
||||
// ─── Platforms & Availability ────────────────────────────────────────
|
||||
|
||||
export const platforms = sqliteTable(
|
||||
"platforms",
|
||||
{
|
||||
id: uuidPk(),
|
||||
name: text("name").notNull(),
|
||||
tmdbProviderId: int("tmdbProviderId"),
|
||||
logoPath: text("logoPath"),
|
||||
urlTemplate: text("urlTemplate"),
|
||||
displayOrder: int("displayOrder").notNull().default(0),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("platforms_tmdbProviderId_unique").on(table.tmdbProviderId),
|
||||
index("platforms_displayOrder").on(table.displayOrder),
|
||||
],
|
||||
);
|
||||
|
||||
export const titleAvailability = sqliteTable(
|
||||
"titleAvailability",
|
||||
{
|
||||
titleId: text("titleId")
|
||||
.notNull()
|
||||
.references(() => titles.id, { onDelete: "cascade" }),
|
||||
region: text("region").notNull().default("US"),
|
||||
providerId: int("providerId").notNull(),
|
||||
providerName: text("providerName").notNull(),
|
||||
logoPath: text("logoPath"),
|
||||
platformId: text("platformId")
|
||||
.notNull()
|
||||
.references(() => platforms.id, { onDelete: "cascade" }),
|
||||
offerType: text("offerType", {
|
||||
enum: ["flatrate", "rent", "buy", "free", "ads"],
|
||||
}).notNull(),
|
||||
link: text("link"),
|
||||
region: text("region").notNull().default("US"),
|
||||
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("availabilityOffers_unique").on(
|
||||
uniqueIndex("titleAvailability_unique").on(
|
||||
table.titleId,
|
||||
table.region,
|
||||
table.providerId,
|
||||
table.platformId,
|
||||
table.offerType,
|
||||
table.region,
|
||||
),
|
||||
index("titleAvailability_titleId").on(table.titleId),
|
||||
index("titleAvailability_platformId").on(table.platformId),
|
||||
],
|
||||
);
|
||||
|
||||
export const userPlatforms = sqliteTable(
|
||||
"userPlatforms",
|
||||
{
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
platformId: text("platformId")
|
||||
.notNull()
|
||||
.references(() => platforms.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("userPlatforms_userId_platformId").on(table.userId, table.platformId),
|
||||
index("userPlatforms_userId").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
import { db } from "./client";
|
||||
import { platforms } from "./schema";
|
||||
|
||||
interface SeedPlatform {
|
||||
tmdbProviderId: number;
|
||||
name: string;
|
||||
urlTemplate: string;
|
||||
displayOrder: number;
|
||||
}
|
||||
|
||||
const SEED_DATA: SeedPlatform[] = [
|
||||
// Netflix
|
||||
{
|
||||
tmdbProviderId: 8,
|
||||
name: "Netflix",
|
||||
urlTemplate: "https://www.netflix.com/search?q={title}",
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 1796,
|
||||
name: "Netflix basic with Ads",
|
||||
urlTemplate: "https://www.netflix.com/search?q={title}",
|
||||
displayOrder: 2,
|
||||
},
|
||||
|
||||
// Amazon
|
||||
{
|
||||
tmdbProviderId: 9,
|
||||
name: "Amazon Prime Video",
|
||||
urlTemplate: "https://www.amazon.com/s?i=instant-video&k={title}",
|
||||
displayOrder: 3,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 10,
|
||||
name: "Amazon Video",
|
||||
urlTemplate: "https://www.amazon.com/s?i=instant-video&k={title}",
|
||||
displayOrder: 4,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 119,
|
||||
name: "Amazon Prime Video",
|
||||
urlTemplate: "https://www.amazon.com/s?i=instant-video&k={title}",
|
||||
displayOrder: 5,
|
||||
},
|
||||
|
||||
// Disney+
|
||||
{
|
||||
tmdbProviderId: 337,
|
||||
name: "Disney+",
|
||||
urlTemplate: "https://www.disneyplus.com/search/{title}",
|
||||
displayOrder: 6,
|
||||
},
|
||||
|
||||
// Apple
|
||||
{
|
||||
tmdbProviderId: 2,
|
||||
name: "Apple iTunes",
|
||||
urlTemplate: "https://tv.apple.com/search?term={title}",
|
||||
displayOrder: 7,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 350,
|
||||
name: "Apple TV+",
|
||||
urlTemplate: "https://tv.apple.com/search?term={title}",
|
||||
displayOrder: 8,
|
||||
},
|
||||
|
||||
// Hulu
|
||||
{
|
||||
tmdbProviderId: 15,
|
||||
name: "Hulu",
|
||||
urlTemplate: "https://www.hulu.com/search?q={title}",
|
||||
displayOrder: 9,
|
||||
},
|
||||
|
||||
// Max (HBO)
|
||||
{
|
||||
tmdbProviderId: 384,
|
||||
name: "HBO Max",
|
||||
urlTemplate: "https://play.max.com/search?q={title}",
|
||||
displayOrder: 10,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 1899,
|
||||
name: "Max",
|
||||
urlTemplate: "https://play.max.com/search?q={title}",
|
||||
displayOrder: 11,
|
||||
},
|
||||
|
||||
// Paramount+
|
||||
{
|
||||
tmdbProviderId: 531,
|
||||
name: "Paramount+",
|
||||
urlTemplate: "https://www.paramountplus.com/search/?q={title}",
|
||||
displayOrder: 12,
|
||||
},
|
||||
|
||||
// Peacock
|
||||
{
|
||||
tmdbProviderId: 386,
|
||||
name: "Peacock",
|
||||
urlTemplate: "https://www.peacocktv.com/search?q={title}",
|
||||
displayOrder: 13,
|
||||
},
|
||||
|
||||
// Google Play
|
||||
{
|
||||
tmdbProviderId: 3,
|
||||
name: "Google Play Movies",
|
||||
urlTemplate: "https://play.google.com/store/search?q={title}&c=movies",
|
||||
displayOrder: 14,
|
||||
},
|
||||
|
||||
// YouTube
|
||||
{
|
||||
tmdbProviderId: 192,
|
||||
name: "YouTube",
|
||||
urlTemplate: "https://www.youtube.com/results?search_query={title}",
|
||||
displayOrder: 15,
|
||||
},
|
||||
|
||||
// Crunchyroll
|
||||
{
|
||||
tmdbProviderId: 283,
|
||||
name: "Crunchyroll",
|
||||
urlTemplate: "https://www.crunchyroll.com/search?q={title}",
|
||||
displayOrder: 16,
|
||||
},
|
||||
|
||||
// Free / ad-supported
|
||||
{
|
||||
tmdbProviderId: 73,
|
||||
name: "Tubi",
|
||||
urlTemplate: "https://tubitv.com/search/{title}",
|
||||
displayOrder: 17,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 300,
|
||||
name: "Pluto TV",
|
||||
urlTemplate: "https://pluto.tv/search/details?q={title}",
|
||||
displayOrder: 18,
|
||||
},
|
||||
|
||||
// Other
|
||||
{
|
||||
tmdbProviderId: 257,
|
||||
name: "fuboTV",
|
||||
urlTemplate: "https://www.fubo.tv/search/{title}",
|
||||
displayOrder: 19,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 43,
|
||||
name: "Starz",
|
||||
urlTemplate: "https://www.starz.com/search?query={title}",
|
||||
displayOrder: 20,
|
||||
},
|
||||
{
|
||||
tmdbProviderId: 37,
|
||||
name: "Showtime",
|
||||
urlTemplate: "https://www.sho.com/search?q={title}",
|
||||
displayOrder: 21,
|
||||
},
|
||||
];
|
||||
|
||||
export function seedPlatforms(): void {
|
||||
const insert = db
|
||||
.insert(platforms)
|
||||
.values({
|
||||
id: sql.placeholder("id"),
|
||||
tmdbProviderId: sql.placeholder("tmdbProviderId"),
|
||||
name: sql.placeholder("name"),
|
||||
urlTemplate: sql.placeholder("urlTemplate"),
|
||||
displayOrder: sql.placeholder("displayOrder"),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: platforms.tmdbProviderId,
|
||||
set: {
|
||||
name: sql`excluded.name`,
|
||||
urlTemplate: sql`excluded.urlTemplate`,
|
||||
displayOrder: sql`excluded.displayOrder`,
|
||||
},
|
||||
})
|
||||
.prepare();
|
||||
|
||||
db.transaction(() => {
|
||||
for (const p of SEED_DATA) {
|
||||
insert.execute({
|
||||
id: Bun.randomUUIDv7(),
|
||||
tmdbProviderId: p.tmdbProviderId,
|
||||
name: p.name,
|
||||
urlTemplate: p.urlTemplate,
|
||||
displayOrder: p.displayOrder,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user