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:
2026-03-24 09:07:14 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent d1b0d952bd
commit 4fc6b2f8d1
76 changed files with 8918 additions and 942 deletions
+82 -12
View File
@@ -23,11 +23,14 @@ import {
ImportPreviewSchema,
IntegrationOutput,
IntegrationsListOutput,
LibraryOutput,
LibraryGenresOutput,
LibraryListInput,
LibraryListOutput,
MediaTypeParam,
PageParam,
PaginatedInput,
ParseFileInput,
PlatformsListOutput,
ParsePayloadInput,
PersonDetailOutput,
PopularOutput,
@@ -53,6 +56,7 @@ import {
TriggerJobInput,
TriggerJobOutput,
UpdateCheckOutput,
UpdateUserPlatformsInput,
UpdateNameInput,
UpdateRatingInput,
UpdateScheduleInput,
@@ -62,8 +66,10 @@ import {
UploadAvatarInput,
UploadAvatarOutput,
UserInfoOutput,
UserPlatformsOutput,
WatchHistoryInput,
WatchHistoryOutput,
WatchProvidersOutput,
} from "./schemas";
export const contract = {
@@ -247,6 +253,31 @@ export const contract = {
},
}),
},
library: {
list: oc
.route({
method: "GET",
path: "/library",
tags: ["Library"],
summary: "List library with filters",
description:
"Fetch paginated, filtered, and sorted titles from the user's library. Supports filtering by status, type, genre, rating, year, content rating, and streaming availability.",
successDescription: "Filtered library items with user statuses and ratings",
})
.input(LibraryListInput)
.output(LibraryListOutput),
genres: oc
.route({
method: "GET",
path: "/library/genres",
tags: ["Library"],
summary: "List genres in user's library",
description:
"Get the distinct genres present in the user's library, ordered alphabetically. Used to populate the genre filter dropdown.",
successDescription: "Genres present in the library",
})
.output(LibraryGenresOutput),
},
dashboard: {
stats: oc
.route({
@@ -270,17 +301,6 @@ export const contract = {
successDescription: "In-progress shows with next episode and watch progress",
})
.output(ContinueWatchingOutput),
library: oc
.route({
method: "GET",
path: "/dashboard/library",
tags: ["Dashboard"],
summary: "Get user library",
description: "Fetch paginated titles in the user's library with their tracking statuses.",
successDescription: "Paginated library items with user statuses",
})
.input(PaginatedInput)
.output(LibraryOutput),
recommendations: oc
.route({
method: "GET",
@@ -372,6 +392,24 @@ export const contract = {
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
},
}),
watchProviders: oc
.route({
method: "GET",
path: "/explore/watch-providers",
tags: ["Explore"],
summary: "List available watch providers",
description:
"Fetch the list of streaming providers available in the configured region. Used to populate provider filter dropdowns.",
successDescription: "Provider list with logos",
})
.input(MediaTypeParam)
.output(WatchProvidersOutput)
.errors({
PRECONDITION_FAILED: {
message: "TMDB API key is not configured",
data: appErrorData(AppErrorCode.TMDB_NOT_CONFIGURED),
},
}),
},
search: oc
.route({
@@ -724,6 +762,38 @@ export const contract = {
})
.input(z.void())
.output(z.void()),
platforms: oc
.route({
method: "GET",
path: "/account/platforms",
tags: ["Account"],
summary: "Get user's streaming platforms",
description: "Fetch the current user's subscribed streaming platform IDs.",
successDescription: "List of platform IDs",
})
.output(UserPlatformsOutput),
updatePlatforms: oc
.route({
method: "PUT",
path: "/account/platforms",
tags: ["Account"],
summary: "Update streaming platforms",
description: "Set the current user's subscribed streaming platforms.",
})
.input(UpdateUserPlatformsInput)
.output(z.void()),
},
platforms: {
list: oc
.route({
method: "GET",
path: "/platforms",
tags: ["Platforms"],
summary: "List all platforms",
description: "Fetch all available streaming platforms, ordered by popularity.",
successDescription: "All platforms with metadata",
})
.output(PlatformsListOutput),
},
imports: {
parseFile: oc
+111 -6
View File
@@ -87,7 +87,26 @@ export const SearchInput = z
export const DiscoverInput = z
.object({
type: z.enum(["movie", "tv"]).describe("Media type to discover"),
genreId: z.number().int().describe("TMDB genre ID to filter by"),
genreId: z.number().int().optional().describe("TMDB genre ID to filter by"),
yearMin: z.number().int().min(1900).max(2100).optional().describe("Minimum release year"),
yearMax: z.number().int().min(1900).max(2100).optional().describe("Maximum release year"),
ratingMin: z.number().min(0).max(10).optional().describe("Minimum TMDB vote average"),
sortBy: z
.enum([
"popularity.desc",
"vote_average.desc",
"primary_release_date.desc",
"primary_release_date.asc",
])
.optional()
.describe("Sort order for results"),
language: z
.string()
.length(2)
.regex(/^[a-z]{2}$/)
.optional()
.describe("ISO 639-1 original language code"),
providerId: z.number().int().optional().describe("TMDB watch provider ID"),
})
.merge(PageParam)
.meta({ description: "Genre-based discovery filters" });
@@ -261,14 +280,39 @@ export const SeasonSchema = z
export const AvailabilityOfferSchema = z
.object({
providerId: z.number().describe("JustWatch provider ID"),
platformId: z.string().describe("Platform ID"),
providerName: z.string().describe("Display name (e.g. Netflix, Hulu)"),
logoPath: z.string().nullable().describe("Provider logo image path"),
offerType: z.string().describe("Offer type: flatrate, rent, buy, free, ads"),
watchUrl: z.string().nullable().describe("Direct link to watch on this provider"),
isUserSubscribed: z.boolean().describe("Whether the user subscribes to this platform"),
})
.meta({ description: "A streaming availability offer from a provider" });
export const PlatformSchema = z
.object({
id: z.string().describe("Platform ID"),
name: z.string().describe("Display name"),
tmdbProviderId: z.number().nullable().describe("TMDB provider ID (null for custom platforms)"),
logoPath: z.string().nullable().describe("Logo image path"),
displayOrder: z.number().describe("Sort order"),
})
.meta({ description: "A streaming platform" });
export type Platform = z.infer<typeof PlatformSchema>;
export const PlatformsListOutput = z.object({
platforms: z.array(PlatformSchema),
});
export const UserPlatformsOutput = z.object({
platformIds: z.array(z.string()),
});
export const UpdateUserPlatformsInput = z.object({
platformIds: z.array(z.string()).describe("List of platform IDs the user subscribes to"),
});
export const CastMemberSchema = z
.object({
id: z.string().describe("Credit ID"),
@@ -507,7 +551,36 @@ export const ContinueWatchingOutput = z
description: "TV shows the user is currently watching with next episode info",
});
export const LibraryOutput = z
// ─── Library (filtered) ───────────────────────────────────────
export const LibraryListInput = z
.object({
search: z.string().max(200).optional().describe("Search within library by title name"),
statuses: z
.array(displayStatusEnum)
.optional()
.describe("Filter by display statuses (multi-select)"),
type: z.enum(["movie", "tv"]).optional().describe("Filter by media type"),
genreId: z.number().int().optional().describe("Filter by TMDB genre ID"),
ratingMin: z.number().int().min(1).max(5).optional().describe("Minimum user star rating"),
ratingMax: z.number().int().min(1).max(5).optional().describe("Maximum user star rating"),
yearMin: z.number().int().min(1900).max(2100).optional().describe("Minimum release year"),
yearMax: z.number().int().min(1900).max(2100).optional().describe("Maximum release year"),
contentRating: z.string().optional().describe("Content rating filter (e.g. PG-13, TV-MA)"),
onMyServices: z
.boolean()
.optional()
.describe("Only show titles available on the user's streaming services"),
sortBy: z
.enum(["title", "added_at", "release_date", "popularity", "user_rating", "vote_average"])
.default("added_at")
.describe("Sort field"),
sortDirection: z.enum(["asc", "desc"]).default("desc").describe("Sort direction"),
})
.merge(PaginatedInput)
.meta({ description: "Filters, sorting, and pagination for the library" });
export const LibraryListOutput = z
.object({
items: z.array(
z
@@ -525,12 +598,36 @@ export const LibraryOutput = z
firstAirDate: z.string().nullable().describe("First air date (ISO 8601)"),
voteAverage: z.number().nullable().describe("Average rating (0-10)"),
userStatus: displayStatusEnum.nullable().describe("User's display status"),
userRating: z.number().nullable().describe("User's star rating (1-5), or null"),
})
.meta({ description: "A library item with user status" }),
.meta({ description: "A library item with user status and rating" }),
),
})
.merge(PaginationMeta)
.meta({ description: "Paginated titles in the user's library" });
.meta({ description: "Filtered and sorted library titles" });
export const LibraryGenresOutput = z
.object({
genres: z
.array(z.object({ id: z.number(), name: z.string() }))
.describe("Genres present in the user's library"),
})
.meta({ description: "Genres that exist in the user's library" });
// ─── Watch providers ──────────────────────────────────────────
export const WatchProvidersOutput = z
.object({
providers: z.array(
z.object({
id: z.string().describe("Platform ID"),
tmdbProviderId: z.number().nullable().describe("TMDB provider ID"),
name: z.string().describe("Provider display name"),
logoPath: z.string().nullable().describe("Provider logo image path"),
}),
),
})
.meta({ description: "Available streaming providers for the user's region" });
export const DashboardRecommendationsOutput = z
.object({
@@ -553,6 +650,14 @@ export const UpcomingInput = z
.describe("How many days into the future to look"),
limit: z.number().int().min(1).max(50).default(20).describe("Maximum items per page"),
cursor: z.string().optional().describe("Pagination cursor"),
mediaType: z
.enum(["movie", "tv"])
.optional()
.describe("Filter to only movies or only TV episodes"),
statusFilter: z
.array(z.enum(["watching", "watchlist"]))
.optional()
.describe("Filter by user tracking status"),
})
.meta({ description: "Filters for the upcoming feed" });
@@ -580,7 +685,7 @@ export const UpcomingItemSchema = z
isNewSeason: z.boolean().describe("Whether this is a new season for a completed show"),
streamingProvider: z
.object({
providerId: z.number(),
platformId: z.string(),
providerName: z.string(),
logoPath: z.string().nullable(),
})
+4
View File
@@ -19,3 +19,7 @@ export const AVATAR_DIR = path.join(DATA_DIR, "avatars");
export const TMDB_API_BASE_URL = process.env.TMDB_API_BASE_URL || "https://api.themoviedb.org/3";
export const TMDB_IMAGE_BASE_URL = process.env.TMDB_IMAGE_BASE_URL || "https://image.tmdb.org/t/p";
// ─── Watch providers ──────────────────────────────────────────
export const WATCH_REGION = process.env.WATCH_REGION || "US";
+2
View File
@@ -14,12 +14,14 @@
"./display-status": "./src/display-status.ts",
"./export": "./src/export.ts",
"./image-cache": "./src/image-cache.ts",
"./library": "./src/library.ts",
"./imports": "./src/imports/index.ts",
"./imports/parsers": "./src/imports/parsers.ts",
"./integrations": "./src/integrations.ts",
"./lists": "./src/lists.ts",
"./metadata": "./src/metadata.ts",
"./person": "./src/person.ts",
"./platforms": "./src/platforms.ts",
"./providers": "./src/providers.ts",
"./settings": "./src/settings.ts",
"./system-health": "./src/system-health.ts",
+12 -10
View File
@@ -1,9 +1,10 @@
import {
getAvailabilityOffers,
ensurePlatformForTmdbProvider,
getAvailabilityForTitle,
replaceAvailabilityTransaction,
} from "@sofa/db/queries/availability";
import { getTitleById } from "@sofa/db/queries/title";
import type { availabilityOffers } from "@sofa/db/schema";
import type { titleAvailability } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { getWatchProviders } from "@sofa/tmdb/client";
@@ -18,21 +19,22 @@ export async function refreshAvailability(titleId: string) {
const now = new Date();
const offerTypes = ["flatrate", "rent", "buy", "free", "ads"] as const;
// Collect all offer rows, then batch insert in a single transaction
const allOfferRows: (typeof availabilityOffers.$inferInsert)[] = [];
const allOfferRows: (typeof titleAvailability.$inferInsert)[] = [];
if (us) {
for (const offerType of offerTypes) {
const providers = us[offerType];
if (!providers) continue;
for (const p of providers) {
const platformId = ensurePlatformForTmdbProvider(
p.provider_id,
p.provider_name ?? "",
p.logo_path ?? null,
);
allOfferRows.push({
titleId,
region: "US",
providerId: p.provider_id,
providerName: p.provider_name ?? "",
logoPath: p.logo_path ?? "",
platformId,
offerType,
link: us.link ?? null,
region: "US",
lastFetchedAt: now,
});
}
@@ -51,5 +53,5 @@ export async function refreshAvailability(titleId: string) {
}
export function getAvailability(titleId: string) {
return getAvailabilityOffers(titleId);
return getAvailabilityForTitle(titleId);
}
+18 -10
View File
@@ -8,7 +8,6 @@ import {
getEpisodeWatchHistoryBuckets,
getHighlyRatedTitleIds,
getInProgressTitleIds,
getLibraryFeed,
getMovieWatchCountSince,
getMovieWatchHistoryBuckets,
getNewAvailableFeed,
@@ -305,8 +304,6 @@ export function getContinueWatchingFeed(userId: string): ContinueWatchingItem[]
export { getNewAvailableFeed } from "@sofa/db/queries/discovery";
export { getLibraryFeed } from "@sofa/db/queries/discovery";
export function getRecommendationsFeed(userId: string) {
// Get recommendations from user's highly-rated or completed titles
const userCompletedOrRated = getEngagedTitleIds(userId);
@@ -373,7 +370,7 @@ export interface UpcomingItem {
userStatus: DisplayStatus;
isNewSeason: boolean;
streamingProvider: {
providerId: number;
platformId: string;
providerName: string;
logoPath: string | null;
} | null;
@@ -386,9 +383,18 @@ export interface UpcomingFeedResult {
export function getUpcomingFeed(
userId: string,
options: { days?: number; limit?: number; cursor?: string } = {},
options: {
days?: number;
limit?: number;
cursor?: string;
mediaType?: "movie" | "tv";
statusFilter?: ("watching" | "watchlist")[];
} = {},
): UpcomingFeedResult {
const { days = 90, limit = 20, cursor } = options;
const { days = 90, limit = 20, cursor, mediaType, statusFilter } = options;
// Map display status filter to stored statuses for DB query
const storedStatuses = statusFilter?.map((s) => (s === "watching" ? "in_progress" : s));
const now = new Date();
const today = formatLocalDate(now);
@@ -412,8 +418,10 @@ export function getUpcomingFeed(
}
}
const fromDate = cursorDate ?? today;
const episodeRows = getUpcomingEpisodes(userId, fromDate, toDate);
const movieRows = getUpcomingMovies(userId, fromDate, toDate);
const episodeRows =
mediaType === "movie" ? [] : getUpcomingEpisodes(userId, fromDate, toDate, storedStatuses);
const movieRows =
mediaType === "tv" ? [] : getUpcomingMovies(userId, fromDate, toDate, storedStatuses);
// Merge into unified items
type RawItem = { date: string; titleId: string; titleName: string } & (
@@ -509,12 +517,12 @@ export function getUpcomingFeed(
const providerRows = getAvailabilityByTitleIds(titleIds);
const providerMap = new Map<
string,
{ providerId: number; providerName: string; logoPath: string | null }
{ platformId: string; providerName: string; logoPath: string | null }
>();
for (const p of providerRows) {
if (!providerMap.has(p.titleId)) {
providerMap.set(p.titleId, {
providerId: p.providerId,
platformId: p.platformId,
providerName: p.providerName,
logoPath: p.logoPath,
});
+39
View File
@@ -0,0 +1,39 @@
import {
getFilteredLibrary,
getLibraryGenres,
type LibraryFilters,
} from "@sofa/db/queries/library";
import { getDisplayStatusesByTitleIds } from "./tracking";
export type { LibraryFilters };
export function getFilteredLibraryFeed(userId: string, filters: LibraryFilters) {
const result = getFilteredLibrary(userId, filters);
const titleIds = result.items.map((i) => i.titleId);
const displayStatuses = getDisplayStatusesByTitleIds(userId, titleIds);
return {
items: result.items.map((item) => ({
titleId: item.titleId,
title: item.title,
type: item.type,
tmdbId: item.tmdbId,
posterPath: item.posterPath,
posterThumbHash: item.posterThumbHash,
releaseDate: item.releaseDate,
firstAirDate: item.firstAirDate,
voteAverage: item.voteAverage,
userStatus: displayStatuses[item.titleId] ?? null,
userRating: item.userRating ?? null,
})),
page: result.page,
totalPages: result.totalPages,
totalResults: result.totalResults,
};
}
export function getLibraryGenresList(userId: string) {
return getLibraryGenres(userId);
}
+17 -6
View File
@@ -30,6 +30,7 @@ import {
upsertSeasonReturning,
} from "@sofa/db/queries/metadata";
import { getTitleById } from "@sofa/db/queries/title";
import { getUserPlatformIds } from "@sofa/db/queries/user-platforms";
import type { titles } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import type { TmdbMovieDetails, TmdbTvDetails, TmdbVideo } from "@sofa/tmdb/client";
@@ -683,17 +684,25 @@ async function ensureEnriched(
return false;
}
function readAvailability(titleId: string, titleName: string): AvailabilityOffer[] {
function readAvailability(
titleId: string,
titleName: string,
userPlatformIds?: Set<string>,
): AvailabilityOffer[] {
return getAvailabilityOffersForTitle(titleId).map((a) => ({
providerId: a.providerId,
platformId: a.platformId,
providerName: a.providerName,
logoPath: tmdbImageUrl(a.logoPath, "logos"),
offerType: a.offerType,
watchUrl: generateProviderUrl(a.providerId, titleName),
watchUrl: generateProviderUrl(a.urlTemplate, titleName),
isUserSubscribed: userPlatformIds ? userPlatformIds.has(a.platformId) : false,
}));
}
export async function getOrFetchTitle(id: string): Promise<{
export async function getOrFetchTitle(
id: string,
userId?: string,
): Promise<{
title: ResolvedTitle;
seasons: Season[];
availability: AvailabilityOffer[];
@@ -740,7 +749,8 @@ export async function getOrFetchTitle(id: string): Promise<{
}
// Read enrichment data, then backfill anything missing
let availability = readAvailability(title.id, title.title);
const userPlatformIdSet = userId ? new Set(getUserPlatformIds(userId)) : undefined;
let availability = readAvailability(title.id, title.title, userPlatformIdSet);
let cast = getCastForTitle(id);
if (title.lastFetchedAt) {
@@ -751,7 +761,8 @@ export async function getOrFetchTitle(id: string): Promise<{
if (enriched) {
// Re-read only what was missing
if (cast.length === 0) cast = getCastForTitle(id);
if (availability.length === 0) availability = readAvailability(title.id, title.title);
if (availability.length === 0)
availability = readAvailability(title.id, title.title, userPlatformIdSet);
title = getTitleById(id) ?? title;
}
}
+31
View File
@@ -0,0 +1,31 @@
import {
getAllPlatforms,
getUserPlatformIds,
getUserPlatforms,
hasUserPlatforms,
platformIdsExist,
setUserPlatforms,
} from "@sofa/db/queries/user-platforms";
export function listPlatforms() {
return getAllPlatforms();
}
export function getUserPlatformList(userId: string) {
return getUserPlatforms(userId);
}
export function getUserPlatformIdList(userId: string) {
return getUserPlatformIds(userId);
}
export function updateUserPlatforms(userId: string, platformIds: string[]): void {
if (platformIds.length > 0 && !platformIdsExist(platformIds)) {
throw new Error("One or more platform IDs do not exist");
}
setUserPlatforms(userId, platformIds);
}
export function hasUserSetPlatforms(userId: string): boolean {
return hasUserPlatforms(userId);
}
+5 -114
View File
@@ -1,117 +1,8 @@
/**
* Provider registry mapping TMDB provider IDs to search URL templates.
*
* To add a new provider:
* 1. Find the TMDB provider_id (visible in availability data or TMDB API)
* 2. Add an entry below with the service's search URL using {title} placeholder
* Generate a watch URL for a provider using its URL template.
* Templates use {title} as a placeholder for the URL-encoded title name.
*/
interface ProviderConfig {
name: string;
searchUrl: string;
}
// TMDB provider_id → search URL config
const providers: Record<number, ProviderConfig> = {
// Netflix
8: { name: "Netflix", searchUrl: "https://www.netflix.com/search?q={title}" },
1796: {
name: "Netflix basic with Ads",
searchUrl: "https://www.netflix.com/search?q={title}",
},
// Amazon
9: {
name: "Amazon Prime Video",
searchUrl: "https://www.amazon.com/s?i=instant-video&k={title}",
},
10: {
name: "Amazon Video",
searchUrl: "https://www.amazon.com/s?i=instant-video&k={title}",
},
119: {
name: "Amazon Prime Video",
searchUrl: "https://www.amazon.com/s?i=instant-video&k={title}",
},
// Disney+
337: {
name: "Disney+",
searchUrl: "https://www.disneyplus.com/search/{title}",
},
// Apple
2: {
name: "Apple iTunes",
searchUrl: "https://tv.apple.com/search?term={title}",
},
350: {
name: "Apple TV+",
searchUrl: "https://tv.apple.com/search?term={title}",
},
// Hulu
15: { name: "Hulu", searchUrl: "https://www.hulu.com/search?q={title}" },
// Max (HBO)
384: { name: "HBO Max", searchUrl: "https://play.max.com/search?q={title}" },
1899: { name: "Max", searchUrl: "https://play.max.com/search?q={title}" },
// Paramount+
531: {
name: "Paramount+",
searchUrl: "https://www.paramountplus.com/search/?q={title}",
},
// Peacock
386: {
name: "Peacock",
searchUrl: "https://www.peacocktv.com/search?q={title}",
},
// Google Play
3: {
name: "Google Play Movies",
searchUrl: "https://play.google.com/store/search?q={title}&c=movies",
},
// YouTube
192: {
name: "YouTube",
searchUrl: "https://www.youtube.com/results?search_query={title}",
},
// Crunchyroll
283: {
name: "Crunchyroll",
searchUrl: "https://www.crunchyroll.com/search?q={title}",
},
// Free / ad-supported
73: { name: "Tubi", searchUrl: "https://tubitv.com/search/{title}" },
300: {
name: "Pluto TV",
searchUrl: "https://pluto.tv/search/details?q={title}",
},
// Other
257: { name: "fuboTV", searchUrl: "https://www.fubo.tv/search/{title}" },
43: {
name: "Starz",
searchUrl: "https://www.starz.com/search?query={title}",
},
37: {
name: "Showtime",
searchUrl: "https://www.sho.com/search?q={title}",
},
};
const providerRegistry: ReadonlyMap<number, ProviderConfig> = new Map(
Object.entries(providers).map(([id, config]) => [Number(id), config]),
);
export function generateProviderUrl(providerId: number, titleName: string): string | null {
const config = providerRegistry.get(providerId);
if (!config) return null;
return config.searchUrl.replace("{title}", encodeURIComponent(titleName));
export function generateProviderUrl(urlTemplate: string | null, titleName: string): string | null {
if (!urlTemplate) return null;
return urlTemplate.replace("{title}", encodeURIComponent(titleName));
}
+13 -5
View File
@@ -1,7 +1,14 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { availabilityOffers } from "@sofa/db/schema";
import { clearAllTables, eq, insertAvailabilityOffer, insertTitle, testDb } from "@sofa/test/db";
import { titleAvailability } from "@sofa/db/schema";
import {
clearAllTables,
eq,
insertPlatform,
insertTitle,
insertTitleAvailability,
testDb,
} from "@sofa/test/db";
const { getWatchProviders } = vi.hoisted(() => ({
getWatchProviders: vi.fn(async () => ({ results: {} as Record<string, unknown> })),
@@ -21,14 +28,15 @@ beforeEach(() => {
describe("refreshAvailability", () => {
test("clears stale US offers when TMDB returns no US availability", async () => {
insertTitle({ id: "movie-1", tmdbId: 101, type: "movie", title: "Movie" });
insertAvailabilityOffer("movie-1");
const platformId = insertPlatform({ id: "p-1", tmdbProviderId: 8 });
insertTitleAvailability("movie-1", platformId);
await refreshAvailability("movie-1");
const offers = testDb
.select()
.from(availabilityOffers)
.where(eq(availabilityOffers.titleId, "movie-1"))
.from(titleAvailability)
.where(eq(titleAvailability.titleId, "movie-1"))
.all();
expect(offers).toHaveLength(0);
+6 -87
View File
@@ -2,7 +2,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vit
import {
clearAllTables,
insertAvailabilityOffer,
insertPlatform,
insertTitleAvailability,
insertEpisodeWatch,
insertMovieWatch,
insertRating,
@@ -15,7 +16,6 @@ import {
import {
getContinueWatchingFeed,
getLibraryFeed,
getNewAvailableFeed,
getRecommendationsFeed,
getRecommendationsForTitle,
@@ -231,7 +231,8 @@ describe("getNewAvailableFeed", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertStatus("user-1", "m1", "watchlist");
insertAvailabilityOffer("m1");
const pId = insertPlatform({ id: "p-m1", tmdbProviderId: 8 });
insertTitleAvailability("m1", pId);
const feed = getNewAvailableFeed("user-1");
expect(feed).toHaveLength(1);
@@ -250,7 +251,8 @@ describe("getNewAvailableFeed", () => {
test("excludes titles not in user library", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertAvailabilityOffer("m1");
const pId = insertPlatform({ id: "p-m1", tmdbProviderId: 8 });
insertTitleAvailability("m1", pId);
const feed = getNewAvailableFeed("user-1");
expect(feed).toHaveLength(0);
@@ -373,86 +375,3 @@ describe("getRecommendationsForTitle", () => {
expect(recs.map((rec) => rec.id)).toEqual(["rec1", "rec2"]);
});
});
// ── getLibraryFeed ────────────────────────────────────────────────────
describe("getLibraryFeed", () => {
test("returns first page with correct pagination metadata", () => {
insertUser();
for (let i = 1; i <= 25; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
insertAvailabilityOffer(`m${i}`);
}
const result = getLibraryFeed("user-1", 1, 20);
expect(result.items).toHaveLength(20);
expect(result.page).toBe(1);
expect(result.totalResults).toBe(25);
expect(result.totalPages).toBe(2);
});
test("returns second page with remaining items", () => {
insertUser();
for (let i = 1; i <= 25; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
insertAvailabilityOffer(`m${i}`);
}
const result = getLibraryFeed("user-1", 2, 20);
expect(result.items).toHaveLength(5);
expect(result.page).toBe(2);
expect(result.totalResults).toBe(25);
expect(result.totalPages).toBe(2);
});
test("custom limit respected", () => {
insertUser();
for (let i = 1; i <= 10; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
insertAvailabilityOffer(`m${i}`);
}
const result = getLibraryFeed("user-1", 1, 5);
expect(result.items).toHaveLength(5);
expect(result.totalPages).toBe(2);
expect(result.totalResults).toBe(10);
});
test("empty page beyond total returns empty items", () => {
insertUser();
for (let i = 1; i <= 5; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
insertAvailabilityOffer(`m${i}`);
}
const result = getLibraryFeed("user-1", 2, 20);
expect(result.items).toHaveLength(0);
expect(result.page).toBe(2);
expect(result.totalResults).toBe(5);
});
test("pages are disjoint and cover all items", () => {
insertUser();
for (let i = 1; i <= 4; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
insertAvailabilityOffer(`m${i}`);
}
const page1 = getLibraryFeed("user-1", 1, 2);
const page2 = getLibraryFeed("user-1", 2, 2);
expect(page1.items).toHaveLength(2);
expect(page2.items).toHaveLength(2);
const allIds = [
...page1.items.map((i) => i.tmdbId),
...page2.items.map((i) => i.tmdbId),
].sort();
expect(allIds).toEqual([1, 2, 3, 4]);
});
});
+145
View File
@@ -0,0 +1,145 @@
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { clearAllTables, insertStatus, insertTitle, insertUser } from "@sofa/test/db";
import { getFilteredLibraryFeed, getLibraryGenresList } from "../src/library";
const defaultFilters = { sortBy: "added_at", sortDirection: "desc" as const, page: 1, limit: 20 };
beforeAll(() => clearAllTables());
beforeEach(() => clearAllTables());
// ── getFilteredLibraryFeed ─────────────────────────────────────────────
describe("getFilteredLibraryFeed", () => {
test("returns first page with correct pagination metadata", () => {
insertUser();
for (let i = 1; i <= 25; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
}
const result = getFilteredLibraryFeed("user-1", { ...defaultFilters });
expect(result.items).toHaveLength(20);
expect(result.page).toBe(1);
expect(result.totalResults).toBe(25);
expect(result.totalPages).toBe(2);
});
test("returns second page with remaining items", () => {
insertUser();
for (let i = 1; i <= 25; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
}
const result = getFilteredLibraryFeed("user-1", { ...defaultFilters, page: 2 });
expect(result.items).toHaveLength(5);
expect(result.page).toBe(2);
expect(result.totalResults).toBe(25);
expect(result.totalPages).toBe(2);
});
test("custom limit respected", () => {
insertUser();
for (let i = 1; i <= 10; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
}
const result = getFilteredLibraryFeed("user-1", { ...defaultFilters, limit: 5 });
expect(result.items).toHaveLength(5);
expect(result.totalPages).toBe(2);
expect(result.totalResults).toBe(10);
});
test("empty page beyond total returns empty items", () => {
insertUser();
for (let i = 1; i <= 5; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
}
const result = getFilteredLibraryFeed("user-1", { ...defaultFilters, page: 2 });
expect(result.items).toHaveLength(0);
expect(result.page).toBe(2);
});
test("pages are disjoint and cover all items", () => {
insertUser();
for (let i = 1; i <= 4; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
}
const page1 = getFilteredLibraryFeed("user-1", { ...defaultFilters, limit: 2 });
const page2 = getFilteredLibraryFeed("user-1", { ...defaultFilters, page: 2, limit: 2 });
expect(page1.items).toHaveLength(2);
expect(page2.items).toHaveLength(2);
const allTitleIds = new Set([
...page1.items.map((i) => i.titleId),
...page2.items.map((i) => i.titleId),
]);
expect(allTitleIds.size).toBe(4);
});
test("filters by type", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1, type: "movie" });
insertTitle({ id: "t1", tmdbId: 2, type: "tv" });
insertStatus("user-1", "m1", "watchlist");
insertStatus("user-1", "t1", "watchlist");
const movies = getFilteredLibraryFeed("user-1", { ...defaultFilters, type: "movie" });
expect(movies.items).toHaveLength(1);
expect(movies.items[0]!.type).toBe("movie");
const tv = getFilteredLibraryFeed("user-1", { ...defaultFilters, type: "tv" });
expect(tv.items).toHaveLength(1);
expect(tv.items[0]!.type).toBe("tv");
});
test("filters by search text", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1, title: "The Matrix" });
insertTitle({ id: "m2", tmdbId: 2, title: "Inception" });
insertStatus("user-1", "m1", "watchlist");
insertStatus("user-1", "m2", "watchlist");
const result = getFilteredLibraryFeed("user-1", { ...defaultFilters, search: "matrix" });
expect(result.items).toHaveLength(1);
expect(result.items[0]!.title).toBe("The Matrix");
});
test("filters by status", () => {
insertUser();
insertTitle({ id: "m1", tmdbId: 1 });
insertTitle({ id: "m2", tmdbId: 2 });
insertStatus("user-1", "m1", "watchlist");
insertStatus("user-1", "m2", "completed");
const watchlist = getFilteredLibraryFeed("user-1", {
...defaultFilters,
statuses: ["in_watchlist"],
});
expect(watchlist.items).toHaveLength(1);
const completed = getFilteredLibraryFeed("user-1", {
...defaultFilters,
statuses: ["completed"],
});
expect(completed.items).toHaveLength(1);
});
});
// ── getLibraryGenresList ──────────────────────────────────────────────
describe("getLibraryGenresList", () => {
test("returns empty array when user has no titles", () => {
insertUser();
const genres = getLibraryGenresList("user-1");
expect(genres).toEqual([]);
});
});
+8 -5
View File
@@ -2,7 +2,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vit
import {
clearAllTables,
insertAvailabilityOffer,
insertPlatform,
insertTitleAvailability,
insertEpisodeWatch,
insertStatus,
insertTitle,
@@ -282,13 +283,14 @@ describe("streaming provider", () => {
const tomorrow = daysFromNow(1);
insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] });
insertStatus("user-1", "tv-1", "in_progress");
insertAvailabilityOffer("tv-1", { providerName: "Netflix", providerId: 8 });
const pId = insertPlatform({ id: "p-netflix", name: "Netflix", tmdbProviderId: 8 });
insertTitleAvailability("tv-1", pId, { offerType: "flatrate" });
const result = getUpcomingFeed("user-1", { days: 7 });
expect(result.items[0].streamingProvider).toEqual({
providerId: 8,
platformId: "p-netflix",
providerName: "Netflix",
logoPath: null,
logoPath: "/logo.png",
});
});
@@ -296,7 +298,8 @@ describe("streaming provider", () => {
const tomorrow = daysFromNow(1);
insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] });
insertStatus("user-1", "tv-1", "in_progress");
insertAvailabilityOffer("tv-1", { offerType: "rent" });
const pId = insertPlatform({ id: "p-rent", tmdbProviderId: 99 });
insertTitleAvailability("tv-1", pId, { offerType: "rent" });
const result = getUpcomingFeed("user-1", { days: 7 });
expect(result.items[0].streamingProvider).toBeNull();
@@ -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,
});
}
});
}
+346
View File
@@ -177,6 +177,10 @@ msgstr "{ratingCount} ratings"
msgid "{star, plural, one {# star} other {# stars}}"
msgstr "{star, plural, one {# star} other {# stars}}"
#: apps/web/src/components/library/library-toolbar.tsx
msgid "{totalResults, plural, one {# result} other {# results}}"
msgstr "{totalResults, plural, one {# result} other {# results}}"
#: apps/web/src/components/settings/imports-section.tsx
msgid "{unresolvedCount} items have no external IDs and will be resolved by title search, which may be less accurate."
msgstr "{unresolvedCount} items have no external IDs and will be resolved by title search, which may be less accurate."
@@ -302,11 +306,36 @@ msgstr ""
msgid "age {age}"
msgstr ""
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
#: apps/native/src/components/explore/filterable-title-row.tsx
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/web/src/components/library/library-filters.tsx
#: apps/web/src/components/library/library-filters.tsx
#: apps/web/src/components/library/library-filters.tsx
#: apps/web/src/components/people/filmography-grid.tsx
#: apps/web/src/routes/_app/upcoming.tsx
#: apps/web/src/routes/_app/upcoming.tsx
#: apps/web/src/routes/_app/upcoming.tsx
#: apps/web/src/routes/_app/upcoming.tsx
msgid "All"
msgstr ""
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/library/library-filters.tsx
#: apps/web/src/components/library/library-filters.tsx
#: apps/web/src/components/library/library-filters.tsx
msgid "All genres"
msgstr "All genres"
#: apps/web/src/components/explore/discover-section.tsx
msgid "All providers"
msgstr "All providers"
#: apps/web/src/components/settings/registration-section.tsx
msgid "Allow new users to create accounts"
msgstr ""
@@ -329,6 +358,22 @@ msgstr ""
msgid "Anonymous usage reporting"
msgstr ""
#: apps/web/src/components/library/library-filters.tsx
msgid "Any"
msgstr "Any"
#: apps/web/src/components/explore/discover-section.tsx
msgid "Any language"
msgstr "Any language"
#: apps/web/src/components/explore/discover-section.tsx
msgid "Any rating"
msgstr "Any rating"
#: apps/web/src/components/explore/discover-section.tsx
msgid "Any year"
msgstr "Any year"
#: apps/web/src/routes/_app/settings.tsx
msgid "App Settings"
msgstr ""
@@ -337,6 +382,10 @@ msgstr ""
msgid "Application"
msgstr ""
#: apps/native/src/components/library/filter-sheet.tsx
msgid "Apply"
msgstr "Apply"
#: apps/native/src/components/settings/integration-card.tsx
msgid "Are you sure you want to disconnect {label}? The current URL will stop working."
msgstr ""
@@ -374,6 +423,13 @@ msgstr ""
msgid "Availability"
msgstr ""
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/web/src/components/library/library-filters.tsx
#: apps/web/src/components/library/library-filters.tsx
msgid "Available to stream"
msgstr "Available to stream"
#: apps/native/src/app/(auth)/register.tsx
msgid "Back to Login"
msgstr ""
@@ -458,8 +514,10 @@ msgstr ""
msgid "Catch up"
msgstr ""
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/native/src/components/titles/status-action-button.tsx
#: apps/web/src/components/dashboard/upcoming-item.tsx
#: apps/web/src/components/library/library-filters.tsx
#: apps/web/src/components/title-card.tsx
#: apps/web/src/components/titles/status-button.tsx
msgid "Caught Up"
@@ -509,6 +567,10 @@ msgstr ""
msgid "Checking…"
msgstr ""
#: apps/web/src/components/explore/discover-section.tsx
msgid "Chinese"
msgstr "Chinese"
#: apps/web/src/components/settings/imports-section.tsx
#~ msgid "Choose how to import your {0} data."
#~ msgstr ""
@@ -521,6 +583,7 @@ msgstr "Choose how to import your {sourceLabel} data."
#~ msgid "Choose your preferred display language"
#~ msgstr "Choose your preferred display language"
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
#: apps/native/src/components/search/recently-viewed-list.tsx
@@ -529,9 +592,18 @@ msgid "Clear"
msgstr ""
#: apps/web/src/components/command-palette.tsx
#: apps/web/src/components/library/library-filters.tsx
msgid "Clear all"
msgstr ""
#: apps/web/src/routes/_app/library.tsx
msgid "Clear all filters"
msgstr "Clear all filters"
#: apps/native/src/app/(tabs)/(library)/index.tsx
msgid "Clear filters"
msgstr "Clear filters"
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
#: apps/native/src/components/search/recently-viewed-list.tsx
msgid "Clear Recently Viewed?"
@@ -561,9 +633,11 @@ msgid "Close"
msgstr ""
#: apps/native/src/app/(tabs)/(home)/index.tsx
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/native/src/components/titles/status-action-button.tsx
#: apps/web/src/components/dashboard/stats-display.tsx
#: apps/web/src/components/dashboard/upcoming-item.tsx
#: apps/web/src/components/library/library-filters.tsx
#: apps/web/src/components/title-card.tsx
#: apps/web/src/components/titles/status-button.tsx
msgid "Completed"
@@ -631,6 +705,16 @@ msgstr ""
msgid "Connecting…"
msgstr ""
#: apps/web/src/components/library/library-filters.tsx
msgid "Content rating"
msgstr "Content rating"
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/web/src/components/library/library-filters.tsx
msgid "Content Rating"
msgstr "Content Rating"
#: apps/native/src/app/(tabs)/(settings)/index.tsx
msgid "Continue"
msgstr ""
@@ -731,10 +815,23 @@ msgstr ""
msgid "Database restored. Reloading..."
msgstr ""
#: apps/native/src/components/library/sort-menu.tsx
#: apps/web/src/components/library/library-toolbar.tsx
msgid "Date Added"
msgstr "Date Added"
#: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Day:"
msgstr ""
#: apps/web/src/components/explore/discover-section.tsx
msgid "Decade"
msgstr "Decade"
#: apps/web/src/components/explore/discover-section.tsx
msgid "Default"
msgstr "Default"
#: apps/web/src/components/settings/backup-section.tsx
#: apps/web/src/components/settings/backup-section.tsx
msgid "Delete"
@@ -784,6 +881,10 @@ msgstr ""
msgid "Disconnect {label}"
msgstr ""
#: apps/web/src/components/explore/discover-section.tsx
msgid "Discover"
msgstr "Discover"
#: apps/native/src/app/(tabs)/(settings)/index.tsx
msgid "Display name"
msgstr "Display name"
@@ -869,6 +970,10 @@ msgstr ""
msgid "Enable the <0>Playback</0> event category"
msgstr ""
#: apps/web/src/components/explore/discover-section.tsx
msgid "English"
msgstr "English"
#: apps/native/src/app/(auth)/login.tsx
#: apps/native/src/app/(auth)/register.tsx
msgid "Enter a valid email address"
@@ -1184,6 +1289,12 @@ msgstr ""
msgid "Filmography"
msgstr ""
#: apps/native/src/app/(tabs)/(library)/index.tsx
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/web/src/components/library/library-toolbar.tsx
msgid "Filters"
msgstr "Filters"
#: apps/web/src/components/settings/imports-section.tsx
msgid "Finished importing from {source}."
msgstr ""
@@ -1200,6 +1311,10 @@ msgstr ""
msgid "Free up disk space by clearing cached metadata and images"
msgstr ""
#: apps/web/src/components/explore/discover-section.tsx
msgid "French"
msgstr "French"
#: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Frequency"
msgstr ""
@@ -1208,6 +1323,24 @@ msgstr ""
msgid "Friday"
msgstr ""
#: apps/web/src/components/library/library-filters.tsx
msgid "From"
msgstr "From"
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/library/library-filters.tsx
#: apps/web/src/components/library/library-filters.tsx
msgid "Genre"
msgstr "Genre"
#: apps/web/src/components/explore/discover-section.tsx
msgid "German"
msgstr "German"
#: apps/web/src/components/landing-page.tsx
msgid "Get Started"
msgstr ""
@@ -1247,6 +1380,14 @@ msgstr ""
msgid "Here's what's happening with your library"
msgstr ""
#: apps/web/src/components/explore/discover-section.tsx
msgid "Highest rated"
msgstr "Highest rated"
#: apps/web/src/components/explore/discover-section.tsx
msgid "Hindi"
msgstr "Hindi"
#: apps/native/src/app/(tabs)/(home)/_layout.tsx
#: apps/native/src/components/navigation/native-tab-bar.tsx
#: apps/web/src/components/nav-bar.tsx
@@ -1377,6 +1518,14 @@ msgstr ""
msgid "Invalid token"
msgstr ""
#: apps/web/src/components/explore/discover-section.tsx
msgid "Italian"
msgstr "Italian"
#: apps/web/src/components/explore/discover-section.tsx
msgid "Japanese"
msgstr "Japanese"
#: apps/web/src/components/settings/system-health-section.tsx
msgid "Job"
msgstr ""
@@ -1394,8 +1543,15 @@ msgstr "Keeping"
msgid "Keyboard Shortcuts"
msgstr ""
#: apps/web/src/components/explore/discover-section.tsx
msgid "Korean"
msgstr "Korean"
#: apps/native/src/app/(tabs)/(settings)/index.tsx
#: apps/native/src/app/(tabs)/(settings)/index.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/settings/language-section.tsx
msgid "Language"
msgstr ""
@@ -1444,7 +1600,12 @@ msgstr ""
msgid "Last run succeeded"
msgstr ""
#: apps/native/src/app/(tabs)/(library)/_layout.tsx
#: apps/native/src/components/navigation/native-tab-bar.tsx
#: apps/web/src/components/nav-bar.tsx
#: apps/web/src/components/nav-bar.tsx
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/routes/_app/library.tsx
msgid "Library"
msgstr "Library"
@@ -1559,14 +1720,32 @@ msgstr ""
#~ msgid "Marked as watching"
#~ msgstr ""
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/web/src/components/library/library-filters.tsx
msgid "Max"
msgstr "Max"
#: apps/web/src/components/library/library-filters.tsx
msgid "Maximum rating"
msgstr "Maximum rating"
#: apps/web/src/components/settings/account-section.tsx
msgid "Member since {memberSince}"
msgstr ""
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/web/src/components/library/library-filters.tsx
msgid "Min"
msgstr "Min"
#: apps/web/src/components/auth-form.tsx
msgid "Min 8 characters…"
msgstr "Min 8 characters…"
#: apps/web/src/components/library/library-filters.tsx
msgid "Minimum rating"
msgstr "Minimum rating"
#: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Monday"
msgstr ""
@@ -1579,18 +1758,28 @@ msgstr ""
msgid "More Settings"
msgstr ""
#: apps/web/src/components/explore/discover-section.tsx
msgid "Most popular"
msgstr "Most popular"
#: apps/native/src/app/title/[id].tsx
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/native/src/components/search/recently-viewed-row-content.tsx
#: apps/native/src/components/search/search-result-row.tsx
#: apps/native/src/components/search/search-result-row.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/explore/hero-banner.tsx
#: apps/web/src/components/library/library-filters.tsx
#: apps/web/src/components/titles/title-hero.tsx
msgid "Movie"
msgstr ""
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
#: apps/web/src/components/people/filmography-grid.tsx
#: apps/web/src/components/settings/account-section.tsx
#: apps/web/src/components/settings/imports-section.tsx
#: apps/web/src/routes/_app/upcoming.tsx
#: apps/web/src/routes/_app/upcoming.tsx
msgid "Movies"
msgstr ""
@@ -1676,6 +1865,7 @@ msgstr ""
msgid "New Season"
msgstr "New Season"
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/people/filmography-grid.tsx
#: apps/web/src/components/people/filmography-grid.tsx
msgid "Newest"
@@ -1708,10 +1898,18 @@ msgstr ""
msgid "No internet connection"
msgstr ""
#: apps/native/src/app/(tabs)/(library)/index.tsx
msgid "No matching titles"
msgstr "No matching titles"
#: apps/native/src/app/(tabs)/(search)/index.tsx
msgid "No results for \"{debouncedQuery}\""
msgstr ""
#: apps/native/src/app/(tabs)/(library)/index.tsx
msgid "No results for \"{debouncedSearch}\""
msgstr "No results for \"{debouncedSearch}\""
#: apps/web/src/components/command-palette.tsx
msgid "No results found."
msgstr ""
@@ -1721,6 +1919,14 @@ msgstr ""
msgid "No titles found for this genre."
msgstr ""
#: apps/web/src/components/explore/discover-section.tsx
msgid "No titles found. Try adjusting your filters."
msgstr "No titles found. Try adjusting your filters."
#: apps/web/src/routes/_app/library.tsx
msgid "No titles match your filters"
msgstr "No titles match your filters"
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
#: apps/web/src/routes/_app/upcoming.tsx
msgid "No upcoming episodes or releases in the next 90 days."
@@ -1736,6 +1942,14 @@ msgstr ""
msgid "Not Found"
msgstr ""
#: apps/web/src/components/library/library-filters.tsx
msgid "Older"
msgstr "Older"
#: apps/web/src/components/explore/discover-section.tsx
msgid "Oldest"
msgstr "Oldest"
#: apps/native/src/components/titles/status-action-button.tsx
#: apps/web/src/components/dashboard/upcoming-item.tsx
#: apps/web/src/components/title-card.tsx
@@ -1904,6 +2118,20 @@ msgstr ""
msgid "Popular TV Shows"
msgstr ""
#: apps/native/src/components/library/sort-menu.tsx
#: apps/web/src/components/library/library-toolbar.tsx
msgid "Popularity"
msgstr "Popularity"
#: apps/web/src/components/explore/discover-section.tsx
msgid "Portuguese"
msgstr "Portuguese"
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/explore/discover-section.tsx
msgid "Pre-1970"
msgstr "Pre-1970"
#: apps/web/src/components/settings/backup-section.tsx
msgid "Pre-restore backup"
msgstr ""
@@ -1929,6 +2157,12 @@ msgstr ""
msgid "Profile picture updated"
msgstr ""
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/explore/discover-section.tsx
msgid "Provider"
msgstr "Provider"
#: apps/web/src/components/settings/danger-section.tsx
#: apps/web/src/components/settings/danger-section.tsx
msgid "Purge all"
@@ -2008,6 +2242,10 @@ msgstr "Rated {ratingStars, plural, one {# star} other {# stars}}"
msgid "Rated {stars, plural, one {# star} other {# stars}}"
msgstr ""
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/library/library-filters.tsx
#: apps/web/src/components/people/filmography-grid.tsx
#: apps/web/src/components/people/filmography-grid.tsx
#: apps/web/src/components/titles/star-rating.tsx
@@ -2117,6 +2355,11 @@ msgstr "Registration failed"
msgid "Registration opened"
msgstr ""
#: apps/native/src/components/library/sort-menu.tsx
#: apps/web/src/components/library/library-toolbar.tsx
msgid "Release Date"
msgstr "Release Date"
#: apps/native/src/components/titles/status-action-button.tsx
#: apps/web/src/components/titles/status-button.tsx
#: apps/web/src/components/titles/status-button.tsx
@@ -2305,6 +2548,14 @@ msgstr ""
msgid "Search for movies, TV shows, or run commands"
msgstr ""
#: apps/web/src/components/library/library-toolbar.tsx
msgid "Search library"
msgstr "Search library"
#: apps/web/src/components/library/library-toolbar.tsx
msgid "Search library..."
msgstr "Search library..."
#: apps/web/src/components/command-palette.tsx
msgid "Search movies & TV shows…"
msgstr ""
@@ -2313,6 +2564,10 @@ msgstr ""
msgid "Search movies, shows, people..."
msgstr ""
#: apps/native/src/app/(tabs)/(library)/index.tsx
msgid "Search your library..."
msgstr "Search your library..."
#: apps/web/src/components/nav-bar.tsx
#: apps/web/src/components/nav-bar.tsx
msgid "Search…"
@@ -2524,15 +2779,28 @@ msgstr ""
msgid "Sonarr List URL"
msgstr ""
#: apps/native/src/components/library/sort-menu.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/library/library-toolbar.tsx
msgid "Sort"
msgstr "Sort"
#: apps/web/src/components/people/filmography-grid.tsx
msgid "Sort filmography"
msgstr "Sort filmography"
#: apps/web/src/components/explore/discover-section.tsx
msgid "Spanish"
msgstr "Spanish"
#: apps/web/src/components/dashboard/stats-section.tsx
msgid "Start exploring"
msgstr ""
#: apps/native/src/app/(tabs)/(home)/index.tsx
#: apps/native/src/app/(tabs)/(library)/index.tsx
msgid "Start tracking movies and shows"
msgstr ""
@@ -2549,6 +2817,11 @@ msgstr ""
msgid "Starting import..."
msgstr ""
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/web/src/components/library/library-filters.tsx
msgid "Status"
msgstr "Status"
#: apps/native/src/hooks/use-title-actions.ts
msgid "Status updated"
msgstr ""
@@ -2561,6 +2834,11 @@ msgstr ""
msgid "Stream"
msgstr ""
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/web/src/components/library/library-filters.tsx
msgid "Streaming"
msgstr "Streaming"
#: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Sunday"
msgstr ""
@@ -2677,11 +2955,21 @@ msgstr ""
msgid "Time:"
msgstr ""
#: apps/native/src/components/library/sort-menu.tsx
#: apps/web/src/components/library/library-toolbar.tsx
msgid "Title A-Z"
msgstr "Title A-Z"
#: apps/native/src/app/title/[id].tsx
#: apps/web/src/routes/_app/titles.$id.tsx
msgid "Title not found"
msgstr ""
#: apps/native/src/components/library/sort-menu.tsx
#: apps/web/src/components/library/library-toolbar.tsx
msgid "Title Z-A"
msgstr "Title Z-A"
#: apps/native/src/components/settings/integration-configs.ts
#: apps/web/src/components/settings/integration-configs.tsx
msgid "Titles on your Sofa watchlist will be automatically added for download when Radarr polls this list (every 12 hours by default)"
@@ -2692,6 +2980,15 @@ msgstr ""
msgid "Titles on your Sofa watchlist will be automatically added for download when Sonarr polls this list (every 6 hours by default)"
msgstr ""
#: apps/native/src/components/library/sort-menu.tsx
#: apps/web/src/components/library/library-toolbar.tsx
msgid "TMDB Rating"
msgstr "TMDB Rating"
#: apps/web/src/components/library/library-filters.tsx
msgid "To"
msgstr "To"
#: apps/native/src/app/(tabs)/(home)/index.tsx
msgid "today"
msgstr "today"
@@ -2736,6 +3033,10 @@ msgstr ""
msgid "Trigger job"
msgstr ""
#: apps/native/src/app/(tabs)/(library)/index.tsx
msgid "Try adjusting your filters"
msgstr "Try adjusting your filters"
#: apps/native/src/app/title/[id].tsx
#: apps/web/src/components/route-error.tsx
#: apps/web/src/routes/__root.tsx
@@ -2747,9 +3048,12 @@ msgid "Tuesday"
msgstr ""
#: apps/native/src/app/title/[id].tsx
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/native/src/components/search/recently-viewed-row-content.tsx
#: apps/native/src/components/search/search-result-row.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/explore/hero-banner.tsx
#: apps/web/src/components/library/library-filters.tsx
#: apps/web/src/components/people/filmography-grid.tsx
#: apps/web/src/components/titles/title-hero.tsx
msgid "TV"
@@ -2763,6 +3067,17 @@ msgstr ""
msgid "TV show"
msgstr ""
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
#: apps/web/src/routes/_app/upcoming.tsx
#: apps/web/src/routes/_app/upcoming.tsx
msgid "TV Shows"
msgstr "TV Shows"
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/web/src/components/library/library-filters.tsx
msgid "Type"
msgstr "Type"
#: apps/web/src/components/settings/system-health-section.tsx
msgid "unknown"
msgstr ""
@@ -2898,6 +3213,11 @@ msgstr "Use at least 8 characters"
msgid "User menu"
msgstr "User menu"
#: apps/native/src/components/library/sort-menu.tsx
#: apps/web/src/components/library/library-toolbar.tsx
msgid "User Rating"
msgstr "User Rating"
#: apps/web/src/components/titles/title-hero.tsx
msgid "View on TMDB"
msgstr "View on TMDB"
@@ -2958,17 +3278,27 @@ msgstr ""
msgid "Watched S{sNum} E{eNum}"
msgstr "Watched S{sNum} E{eNum}"
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/native/src/components/titles/status-action-button.tsx
#: apps/web/src/components/dashboard/upcoming-item.tsx
#: apps/web/src/components/library/library-filters.tsx
#: apps/web/src/components/title-card.tsx
#: apps/web/src/components/titles/status-button.tsx
#: apps/web/src/routes/_app/upcoming.tsx
#: apps/web/src/routes/_app/upcoming.tsx
msgid "Watching"
msgstr ""
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/native/src/components/titles/status-action-button.tsx
#: apps/web/src/components/library/library-filters.tsx
#: apps/web/src/components/settings/imports-section.tsx
#: apps/web/src/components/settings/imports-section.tsx
#: apps/web/src/components/titles/status-button.tsx
#: apps/web/src/routes/_app/upcoming.tsx
#: apps/web/src/routes/_app/upcoming.tsx
msgid "Watchlist"
msgstr ""
@@ -3014,6 +3344,21 @@ msgstr ""
msgid "Writer"
msgstr ""
#: apps/native/src/components/library/filter-sheet.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/explore/discover-section.tsx
#: apps/web/src/components/library/library-filters.tsx
msgid "Year"
msgstr "Year"
#: apps/web/src/components/library/library-filters.tsx
msgid "Year from"
msgstr "Year from"
#: apps/web/src/components/library/library-filters.tsx
msgid "Year to"
msgstr "Year to"
#: apps/native/src/app/(tabs)/(settings)/index.tsx
msgid "You'll be signed out to change the server URL."
msgstr ""
@@ -3031,6 +3376,7 @@ msgid "Your code:"
msgstr ""
#: apps/native/src/app/(tabs)/(home)/index.tsx
#: apps/native/src/app/(tabs)/(library)/index.tsx
#: apps/web/src/components/dashboard/stats-section.tsx
msgid "Your library is empty"
msgstr ""
+39 -7
View File
@@ -16,7 +16,9 @@ const {
userEpisodeWatches,
userTitleStatus,
userRatings,
availabilityOffers,
platforms,
titleAvailability,
userPlatforms,
titleRecommendations,
integrations,
} = schema;
@@ -171,25 +173,55 @@ export function insertRating(userId: string, titleId: string, ratingStars: numbe
testDb.insert(userRatings).values({ userId, titleId, ratingStars, ratedAt: new Date() }).run();
}
export function insertAvailabilityOffer(
export function insertPlatform(
overrides: {
id?: string;
name?: string;
tmdbProviderId?: number;
logoPath?: string;
urlTemplate?: string;
displayOrder?: number;
} = {},
) {
const id = overrides.id ?? "platform-1";
testDb
.insert(platforms)
.values({
id,
name: overrides.name ?? "Netflix",
tmdbProviderId: overrides.tmdbProviderId ?? 8,
logoPath: overrides.logoPath ?? "/logo.png",
urlTemplate: overrides.urlTemplate ?? "https://www.netflix.com/search?q={title}",
displayOrder: overrides.displayOrder ?? 1,
})
.run();
return id;
}
export function insertTitleAvailability(
titleId: string,
platformId: string,
overrides: {
providerId?: number;
providerName?: string;
offerType?: "flatrate" | "rent" | "buy" | "free" | "ads";
region?: string;
} = {},
) {
testDb
.insert(availabilityOffers)
.insert(titleAvailability)
.values({
titleId,
providerId: overrides.providerId ?? 8,
providerName: overrides.providerName ?? "Netflix",
platformId,
offerType: overrides.offerType ?? "flatrate",
region: overrides.region ?? "US",
lastFetchedAt: new Date(),
})
.run();
}
export function insertUserPlatform(userId: string, platformId: string) {
testDb.insert(userPlatforms).values({ userId, platformId }).run();
}
export function insertIntegration(userId: string, provider: string, token = "test-token") {
const type = provider === "sonarr" || provider === "radarr" ? "list" : "webhook";
return testDb
+23
View File
@@ -261,6 +261,29 @@ export async function getWatchProviders(tmdbId: number, type: "movie" | "tv") {
return data as TmdbWatchProviderResponse;
}
export interface TmdbWatchProviderListItem {
provider_id: number;
provider_name: string;
logo_path: string | null;
display_priorities: Record<string, number>;
}
export async function getWatchProviderList(type: "movie" | "tv", watchRegion?: string) {
const query = watchRegion ? ({ watch_region: watchRegion } as Record<string, unknown>) : {};
if (type === "movie") {
const { data, error } = await client.GET("/3/watch/providers/movie", {
params: { query },
});
if (error) throw new Error("TMDB API error: watch/providers/movie");
return (data as { results?: TmdbWatchProviderListItem[] }).results ?? [];
}
const { data, error } = await client.GET("/3/watch/providers/tv", {
params: { query },
});
if (error) throw new Error("TMDB API error: watch/providers/tv");
return (data as { results?: TmdbWatchProviderListItem[] }).results ?? [];
}
// ─── Recommendations & Similar ──────────────────────────────────────
export async function getRecommendations(tmdbId: number, type: "movie" | "tv") {