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