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:
jakeandClaude Opus 4.6 authored and GitHub committed 2026-03-24 09:07:14 -04:00
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
+2 -1
View File
@@ -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",
+50 -8
View File
@@ -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;
}
+10 -10
View File
@@ -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),
);
+49 -70
View File
@@ -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();
+13 -4
View File
@@ -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();
}
+329
View File
@@ -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();
}
+15 -2
View File
@@ -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 ─────────────────────────────────────────────────
+68
View File
@@ -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
View File
@@ -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),
],
);
+198
View File
@@ -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,
});
}
});
}