diff --git a/apps/native/src/app/title/[id].tsx b/apps/native/src/app/title/[id].tsx index ff9dc5c..456fe30 100644 --- a/apps/native/src/app/title/[id].tsx +++ b/apps/native/src/app/title/[id].tsx @@ -471,30 +471,76 @@ export default function TitleDetailScreen() { iconColor={titleAccent} /> - - {availability.map((offer) => ( - - {offer.logoPath && ( - + {(() => { + const stream = availability.filter((o) => o.offerType === "stream"); + const purchase = availability.filter((o) => o.offerType === "purchase"); + return ( + + {stream.length > 0 && ( + + + {t`Stream`} + + + {stream.map((offer) => ( + + {offer.logoPath && ( + + )} + + {offer.providerName} + + + ))} + + + )} + {purchase.length > 0 && ( + + + {t`Buy or Rent`} + + + {purchase.map((offer) => ( + + {offer.logoPath && ( + + )} + + {offer.providerName} + + + ))} + + )} - - {offer.providerName} - - ))} - + ); + })()} )} diff --git a/apps/server/src/cron.ts b/apps/server/src/cron.ts index 75fe351..6caa51f 100644 --- a/apps/server/src/cron.ts +++ b/apps/server/src/cron.ts @@ -58,14 +58,14 @@ function schedule(name: string, cron: string, handler: () => Promise) { jobs.set( name, new Cron(cron, { name, protect: true }, async () => { - log.info(`Running job: ${name}`); + log.debug(`Running job: ${name}`); const startMs = performance.now(); const run = startCronRun(name); try { await handler(); const durationMs = Math.round(performance.now() - startMs); completeCronRun(run.id, durationMs); - log.info(`Completed job: ${name} (${durationMs}ms)`); + log.debug(`Completed job: ${name} (${durationMs}ms)`); } catch (err) { const durationMs = Math.round(performance.now() - startMs); failCronRun(run.id, durationMs, err); @@ -285,7 +285,7 @@ export function rescheduleBackup() { jobs.delete("scheduledBackup"); } const cron = getBackupCronFromSettings(); - log.info(`Rescheduling backup job with cron: ${cron}`); + log.debug(`Rescheduling backup job with cron: ${cron}`); schedule("scheduledBackup", cron, scheduledBackupJob); } @@ -327,7 +327,7 @@ export function startJobs() { optimizeDatabase(); }); - log.info(`Started ${jobs.size} jobs`); + log.debug(`Registered ${jobs.size} scheduled jobs`); } export function pauseJobs() { diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 4a0fcb6..0fe04bb 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -43,7 +43,7 @@ seedPlatforms(); // Recover import jobs left in running/pending state from a previous crash const recoveredJobs = recoverStaleImportJobs(); if (recoveredJobs > 0) { - log.info(`Recovered ${recoveredJobs} stale import job(s) from previous shutdown`); + log.warn(`Recovered ${recoveredJobs} stale import job(s) from previous shutdown`); } // Wire up job schedule provider for system health @@ -157,9 +157,17 @@ process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); const port = Number(process.env.PORT || process.env.API_PORT || 3001); -log.info(`API server listening on port ${port}`); - -export default { +const server = Bun.serve({ port, fetch: app.fetch, -}; +}); + +log.info( + `🍿 Sofa${process.env.APP_VERSION ? ` v${process.env.APP_VERSION}` : ""}: Now playing at ${server.url.origin}`, + { + address: server.hostname, + port: server.port, + }, +); + +export default server; diff --git a/apps/server/src/orpc/procedures/discover.ts b/apps/server/src/orpc/procedures/discover.ts index 8b1985c..0387758 100644 --- a/apps/server/src/orpc/procedures/discover.ts +++ b/apps/server/src/orpc/procedures/discover.ts @@ -3,6 +3,7 @@ import { ORPCError } from "@orpc/server"; import { AppErrorCode } from "@sofa/api/errors"; import { WATCH_REGION } from "@sofa/config"; import { ensureBrowseTitlesExist } from "@sofa/core/metadata"; +import { getPlatformTmdbIds } from "@sofa/core/platforms"; import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking"; import { discover as discoverTmdb } from "@sofa/tmdb/client"; import { isTmdbConfigured } from "@sofa/tmdb/config"; @@ -34,9 +35,12 @@ export const discover = os.discover.use(authed).handler(async ({ input, context } if (input.ratingMin != null) params["vote_average.gte"] = String(input.ratingMin); if (input.language) params.with_original_language = input.language; - if (input.providerId) { - params.with_watch_providers = String(input.providerId); - params.watch_region = WATCH_REGION; + if (input.platformId) { + const tmdbIds = getPlatformTmdbIds(input.platformId); + if (tmdbIds.length > 0) { + params.with_watch_providers = tmdbIds.join("|"); + params.watch_region = WATCH_REGION; + } } const results = await discoverTmdb(input.type, params, input.page); diff --git a/apps/server/src/orpc/procedures/explore.ts b/apps/server/src/orpc/procedures/explore.ts index 2970353..fbe7980 100644 --- a/apps/server/src/orpc/procedures/explore.ts +++ b/apps/server/src/orpc/procedures/explore.ts @@ -2,7 +2,7 @@ import { ORPCError } from "@orpc/server"; import { AppErrorCode } from "@sofa/api/errors"; import { ensureBrowseTitlesExist } from "@sofa/core/metadata"; -import { listPlatforms } from "@sofa/core/platforms"; +import { getPlatformTmdbIdMap, listPlatforms } from "@sofa/core/platforms"; import { getEpisodeProgressByTitleIds, getDisplayStatusesByTitleIds } from "@sofa/core/tracking"; import { getGenres, getPopular, getTrending } from "@sofa/tmdb/client"; import { isTmdbConfigured } from "@sofa/tmdb/config"; @@ -165,10 +165,11 @@ export const genres = os.explore.genres.use(authed).handler(async ({ input }) => export const watchProviders = os.explore.watchProviders.use(authed).handler(async () => { const allPlatforms = listPlatforms(); + const tmdbIdsMap = getPlatformTmdbIdMap(allPlatforms.map((p) => p.id)); return { providers: allPlatforms.map((p) => ({ id: p.id, - tmdbProviderId: p.tmdbProviderId, + tmdbProviderIds: tmdbIdsMap.get(p.id) ?? [], name: p.name, logoPath: tmdbImageUrl(p.logoPath, "logos"), })), diff --git a/apps/server/src/orpc/procedures/platforms.ts b/apps/server/src/orpc/procedures/platforms.ts index dcade39..51ed376 100644 --- a/apps/server/src/orpc/procedures/platforms.ts +++ b/apps/server/src/orpc/procedures/platforms.ts @@ -1,4 +1,4 @@ -import { listPlatforms } from "@sofa/core/platforms"; +import { listPlatforms, getPlatformTmdbIdMap } from "@sofa/core/platforms"; import { tmdbImageUrl } from "@sofa/tmdb/image"; import { os } from "../context"; @@ -6,13 +6,14 @@ import { authed } from "../middleware"; export const list = os.platforms.list.use(authed).handler(async () => { const allPlatforms = listPlatforms(); + const tmdbIdsMap = getPlatformTmdbIdMap(allPlatforms.map((p) => p.id)); return { platforms: allPlatforms.map((p) => ({ id: p.id, name: p.name, - tmdbProviderId: p.tmdbProviderId, + tmdbProviderIds: tmdbIdsMap.get(p.id) ?? [], logoPath: tmdbImageUrl(p.logoPath, "logos"), - displayOrder: p.displayOrder, + isSubscription: p.isSubscription, })), }; }); diff --git a/apps/web/src/components/dashboard/sparkline.tsx b/apps/web/src/components/dashboard/sparkline.tsx index 62e3ef3..237149a 100644 --- a/apps/web/src/components/dashboard/sparkline.tsx +++ b/apps/web/src/components/dashboard/sparkline.tsx @@ -109,8 +109,6 @@ export function Sparkline({ data, color }: SparklineProps) { return computeMonotonePath(points, size.height); }, [data, size.width, size.height]); - if (!data.some((d) => d.count > 0)) return null; - return (
(value ? periodLabels[value] : null)} - + {periods.map((p) => ( {periodLabels[p]} diff --git a/apps/web/src/components/explore/discover-section.tsx b/apps/web/src/components/explore/discover-section.tsx index b9d3006..fbc20d9 100644 --- a/apps/web/src/components/explore/discover-section.tsx +++ b/apps/web/src/components/explore/discover-section.tsx @@ -67,8 +67,7 @@ export function DiscoverSection() { | "primary_release_date.asc"; const [sortBy, setSortBy] = useState(undefined); const [language, setLanguage] = useState(undefined); - const [providerId, setProviderId] = useState(undefined); - const [selectedPlatformId, setSelectedPlatformId] = useState(""); + const [platformId, setPlatformId] = useState(undefined); const { data: genreData } = useQuery(orpc.explore.genres.queryOptions({ input: { type } })); const { data: providerData } = useQuery(orpc.platforms.list.queryOptions()); @@ -83,7 +82,7 @@ export function DiscoverSection() { ratingMin, sortBy, language, - providerId, + platformId, page: pageParam, }), initialPageParam: 1, @@ -154,14 +153,7 @@ export function DiscoverSection() { } function handleProviderChange(value: string | null) { - setSelectedPlatformId(value ?? ""); - if (!value) { - setProviderId(undefined); - return; - } - // Resolve platform UUID to TMDB provider ID for the discover API - const platform = providers.find((p) => p.id === value); - setProviderId(platform?.tmdbProviderId ?? undefined); + setPlatformId(value || undefined); } function handleGenreChange(value: string | null) { @@ -183,8 +175,7 @@ export function DiscoverSection() { if (next === "movie" || next === "tv") { setType(next); setGenreId(undefined); - setProviderId(undefined); - setSelectedPlatformId(""); + setPlatformId(undefined); } }} variant="outline" @@ -217,7 +208,7 @@ export function DiscoverSection() { }} - + {t`All genres`} {genres.map((genre) => ( @@ -248,7 +239,7 @@ export function DiscoverSection() { }} - + {t`Any year`} {DECADE_PRESETS.map((d) => ( @@ -277,7 +268,7 @@ export function DiscoverSection() { }} - + {t`Any rating`} {RATING_PRESETS.map((r) => ( @@ -306,7 +297,7 @@ export function DiscoverSection() { }} - + {t`Default`} {SORT_OPTIONS.map((s) => ( @@ -335,7 +326,7 @@ export function DiscoverSection() { }} - + {t`Any language`} {LANGUAGE_OPTIONS.map((lang) => ( @@ -347,14 +338,14 @@ export function DiscoverSection() { {/* Provider select */} @@ -286,7 +286,7 @@ export function LibraryFilters({ > {(value: string | null) => (value ? value : t`Age`)} - + {t`All`} {CONTENT_RATINGS.map((rating) => ( diff --git a/apps/web/src/components/library/library-toolbar.tsx b/apps/web/src/components/library/library-toolbar.tsx index 609755a..5242703 100644 --- a/apps/web/src/components/library/library-toolbar.tsx +++ b/apps/web/src/components/library/library-toolbar.tsx @@ -68,20 +68,20 @@ export function LibraryToolbar({ value={search} onChange={(e) => onSearchChange(e.target.value)} placeholder={t`Search library...`} - className="pl-7" + className="py-3 pl-7" aria-label={t`Search library`} />
- + {plural(totalResults, { one: "# result", other: "# results" })} {/* Filter toggle */} + diff --git a/apps/web/src/components/people/filmography-grid.tsx b/apps/web/src/components/people/filmography-grid.tsx index 02ac7b8..b81ed3f 100644 --- a/apps/web/src/components/people/filmography-grid.tsx +++ b/apps/web/src/components/people/filmography-grid.tsx @@ -82,7 +82,7 @@ export function FilmographyGrid({ credits, userStatuses }: FilmographyGridProps) } - + Newest diff --git a/apps/web/src/components/platforms/platform-grid.tsx b/apps/web/src/components/platforms/platform-grid.tsx index 48a69e9..8537257 100644 --- a/apps/web/src/components/platforms/platform-grid.tsx +++ b/apps/web/src/components/platforms/platform-grid.tsx @@ -25,7 +25,7 @@ export function PlatformGrid({ platforms, selectedIds, onToggle }: PlatformGridP className={`group relative flex flex-col items-center gap-2 rounded-xl border p-3 motion-safe:transition-colors motion-safe:duration-150 ${ isSelected ? "border-primary/50 bg-primary/8" - : "border-border/20 hover:border-primary/30 hover:bg-primary/5" + : "border-border/50 hover:border-primary/30 hover:bg-primary/5" }`} aria-label={(() => { const name = platform.name; diff --git a/apps/web/src/components/settings/account-section.tsx b/apps/web/src/components/settings/account-section.tsx index efdaad2..53f1de0 100644 --- a/apps/web/src/components/settings/account-section.tsx +++ b/apps/web/src/components/settings/account-section.tsx @@ -10,7 +10,6 @@ import { IconLogout, IconPencil, IconTrash, - IconUser, IconX, } from "@tabler/icons-react"; import { useMutation } from "@tanstack/react-query"; @@ -164,208 +163,197 @@ export function AccountSection({ } return ( -
-
- -

- Account -

-
- - - {/* Avatar: click to upload (no avatar) or remove (has avatar) */} - - fileInputRef.current?.click()} - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - disabled={isAvatarPending} - /> - } - className="focus-visible:ring-ring focus-visible:ring-offset-background relative shrink-0 cursor-pointer rounded-full focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none" - aria-label={avatarUrl ? t`Remove profile picture` : t`Upload profile picture`} - > - - - - {initial} - - + + + {/* Avatar: click to upload (no avatar) or remove (has avatar) */} + + fileInputRef.current?.click()} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + disabled={isAvatarPending} + /> + } + className="focus-visible:ring-ring focus-visible:ring-offset-background relative shrink-0 cursor-pointer rounded-full focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none" + aria-label={avatarUrl ? t`Remove profile picture` : t`Upload profile picture`} + > + + + + {initial} + + - - {(isHovered || isAvatarPending) && ( - - {isAvatarPending ? ( - - ) : avatarUrl ? ( - - ) : ( - - )} - - )} - - - - {avatarUrl ? Remove picture : Upload picture} - - - - - -
- - - {isEditingName ? ( - -
- - setEditValue(e.target.value)} - onKeyDown={handleNameKeyDown} - onBlur={handleNameSave} - disabled={isNamePending} - maxLength={100} - className="border-primary/40 focus:border-primary col-start-1 row-start-1 min-w-4 border-0 border-b border-dashed bg-transparent text-sm font-medium transition-colors outline-none" - /> -
- {isNamePending ? ( - - ) : ( - <> - - - - )} -
- ) : ( - setIsEditingName(true)} - className="group/name hover:text-primary inline-flex items-center gap-1.5 rounded-md px-0 text-start transition-colors" - > - {displayName} - - - )} -
-
- - {user.email} - {user.role === "admin" && ( - - Admin - + + {(isHovered || isAvatarPending) && ( + + {isAvatarPending ? ( + + ) : avatarUrl ? ( + + ) : ( + + )} + )} - -

- Member since {memberSince} + + + + {avatarUrl ? Remove picture : Upload picture} + + + + + +

+ + + {isEditingName ? ( + +
+ + setEditValue(e.target.value)} + onKeyDown={handleNameKeyDown} + onBlur={handleNameSave} + disabled={isNamePending} + maxLength={100} + className="border-primary/40 focus:border-primary col-start-1 row-start-1 min-w-4 border-0 border-b border-dashed bg-transparent text-sm font-medium transition-colors outline-none" + /> +
+ {isNamePending ? ( + + ) : ( + <> + + + + )} +
+ ) : ( + setIsEditingName(true)} + className="group/name hover:text-primary inline-flex items-center gap-1.5 rounded-md px-0 text-start transition-colors" + > + {displayName} + + + )} +
+
+ + {user.email} + {user.role === "admin" && ( + + Admin + + )} + +

+ Member since {memberSince} +

+
+ +
+ + +
+ + +
+ -
- - -
- - -
- -
+ + + +
+ ); } diff --git a/apps/web/src/components/settings/backup-schedule-section.tsx b/apps/web/src/components/settings/backup-schedule-section.tsx index a17439e..5be2802 100644 --- a/apps/web/src/components/settings/backup-schedule-section.tsx +++ b/apps/web/src/components/settings/backup-schedule-section.tsx @@ -233,7 +233,7 @@ export function BackupScheduleSection() { } - + {[3, 5, 7, 14, 30, 0].map((n) => ( {n === 0 ? t`unlimited` : t`last ${n}`} @@ -319,7 +319,7 @@ export function BackupScheduleSection() { } - + {DAYS_OF_WEEK.map((day, i) => ( {day} @@ -368,7 +368,7 @@ export function BackupScheduleSection() { } - + {HOURS.map((h) => { const val = `${String(h).padStart(2, "0")}:00`; return ( diff --git a/apps/web/src/components/settings/imports-section.tsx b/apps/web/src/components/settings/imports-section.tsx index 77da48e..9dfa364 100644 --- a/apps/web/src/components/settings/imports-section.tsx +++ b/apps/web/src/components/settings/imports-section.tsx @@ -1,5 +1,5 @@ import { Trans, useLingui } from "@lingui/react/macro"; -import { IconCloudUpload, IconFileImport, IconLink } from "@tabler/icons-react"; +import { IconCloudUpload, IconLink } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; @@ -99,7 +99,7 @@ const SOURCES: SourceConfig[] = [ { source: "trakt", label: "Trakt", - description: "Connect your Trakt account or upload a JSON export.", + description: "Connect your Trakt account or upload a JSON export", accept: ".json", icon: , supportsOAuth: true, @@ -107,7 +107,7 @@ const SOURCES: SourceConfig[] = [ { source: "simkl", label: "Simkl", - description: "Connect your Simkl account or upload a JSON export.", + description: "Connect your Simkl account or upload a JSON export", accept: ".json", icon: , supportsOAuth: true, @@ -115,7 +115,7 @@ const SOURCES: SourceConfig[] = [ { source: "letterboxd", label: "Letterboxd", - description: "Upload the ZIP export from your Letterboxd account settings.", + description: "Upload the ZIP export from your Letterboxd account settings", accept: ".zip", icon: , supportsOAuth: false, @@ -168,18 +168,10 @@ type DialogStep = export function ImportsSection() { return ( -
-
- -

- Import -

-
-
- {SOURCES.map((config) => ( - - ))} -
+
+ {SOURCES.map((config) => ( + + ))}
); } diff --git a/apps/web/src/components/settings/integrations-section.tsx b/apps/web/src/components/settings/integrations-section.tsx index 2603d46..471f1f6 100644 --- a/apps/web/src/components/settings/integrations-section.tsx +++ b/apps/web/src/components/settings/integrations-section.tsx @@ -1,5 +1,3 @@ -import { Trans } from "@lingui/react/macro"; -import { IconWebhook } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; @@ -25,35 +23,24 @@ export function IntegrationsSection() { }); } - return ( -
-
- -

- Integrations -

-
- {isPending ? ( -
- {INTEGRATION_CONFIGS.map((c) => ( - - ))} -
- ) : ( -
- {INTEGRATION_CONFIGS.map((config) => ( - c.provider === config.provider) ?? - null - } - setConnections={handleSetConnections} - /> - ))} -
- )} + return isPending ? ( +
+ {INTEGRATION_CONFIGS.map((c) => ( + + ))} +
+ ) : ( +
+ {INTEGRATION_CONFIGS.map((config) => ( + c.provider === config.provider) ?? null + } + setConnections={handleSetConnections} + /> + ))}
); } diff --git a/apps/web/src/components/settings/settings-shell.tsx b/apps/web/src/components/settings/settings-shell.tsx index 791740b..c862844 100644 --- a/apps/web/src/components/settings/settings-shell.tsx +++ b/apps/web/src/components/settings/settings-shell.tsx @@ -15,7 +15,7 @@ const sectionVariants = { export function SettingsShell({ children, footer }: { children: ReactNode; footer?: ReactNode }) { return (
- Streaming Services + Subscriptions {selectedCount > 0 ? ( t`${selectedCount} selected` ) : ( - Choose your subscriptions + Choose your preferred streaming platforms )}
-
+
{saved && ( + Saved )} @@ -121,9 +122,38 @@ export function StreamingServicesSection() { + ); } + +function JustWatchLogo(props: SVGProps) { + return ( + + {/* Icon from Custom Brand Icons by Emanuele & rchiileea - https://github.com/elax46/custom-brand-icons/blob/main/LICENSE */} + + + ); +} diff --git a/apps/web/src/components/settings/system-health-section.tsx b/apps/web/src/components/settings/system-health-section.tsx index 6f7e371..499a11e 100644 --- a/apps/web/src/components/settings/system-health-section.tsx +++ b/apps/web/src/components/settings/system-health-section.tsx @@ -125,7 +125,7 @@ export function SystemHealthCards() { if (isPending || !data) return ; return ( -
+
+ ), } : {})} @@ -50,7 +44,7 @@ function ProviderBadge({ )} - {watchUrl ? t`Watch on ${name}` : name} + {name} ); @@ -159,11 +153,8 @@ function OffersByType({ export function TitleAvailability({ availability }: { availability: AvailabilityOffer[] }) { const { t } = useLingui(); const offerLabels: Record = { - flatrate: t`Stream`, - rent: t`Rent`, - buy: t`Buy`, - free: t`Free`, - ads: t`With Ads`, + stream: t`Stream`, + purchase: t`Buy or Rent`, }; if (availability.length === 0) return null; diff --git a/apps/web/src/routes/_app/settings.tsx b/apps/web/src/routes/_app/settings.tsx index 54b64ca..87b4711 100644 --- a/apps/web/src/routes/_app/settings.tsx +++ b/apps/web/src/routes/_app/settings.tsx @@ -2,9 +2,11 @@ import { Trans } from "@lingui/react/macro"; import { IconAlertTriangle, IconDatabaseExport, - IconDeviceDesktopCog, + IconFileImport, IconServer2, IconShieldLock, + IconUser, + IconWebhook, } from "@tabler/icons-react"; import { createFileRoute } from "@tanstack/react-router"; @@ -77,78 +79,50 @@ function SettingsPage() { const isAdmin = session.user.role === "admin"; return ( - -

- - Sofa - {" "} - v{__APP_VERSION__} - {__GIT_COMMIT_SHA__ && ( - <> - {" "} - ( - - {__GIT_COMMIT_SHA__} - - ) - - )} -

-
- - - -

- - This product uses the TMDB API but is not endorsed or certified by TMDB. - -

-
- - } - > - - - {/* App Settings */} +
- +

- App Settings + Account

- - +
+ + + +
- + {/* Integrations */} +
+
+ +

+ Integrations +

+
+ +
- + {/* Import */} +
+
+ +

+ Import +

+
+ +
{/* Server health */} {isAdmin && ( @@ -178,7 +152,7 @@ function SettingsPage() { Admin only
-
+
@@ -201,7 +175,7 @@ function SettingsPage() { Admin only
-
+
@@ -232,6 +206,48 @@ function SettingsPage() {
)} + +
+

+ + Sofa + {" "} + v{__APP_VERSION__} + {__GIT_COMMIT_SHA__ && ( + <> + {" "} + ( + + {__GIT_COMMIT_SHA__} + + ) + + )} +

+
+ + + +

+ This product uses the TMDB API but is not endorsed or certified by TMDB. +

+
+
); } diff --git a/bun.lock b/bun.lock index 5497eff..2687db6 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "apps/native": { "name": "@sofa/native", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@better-auth/expo": "catalog:", "@expo/metro-runtime": "55.0.6", @@ -112,7 +112,7 @@ }, "apps/public-api": { "name": "@sofa/public-api", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@hono/zod-validator": "0.7.6", "@vercel/firewall": "1.1.2", @@ -127,7 +127,7 @@ }, "apps/server": { "name": "@sofa/server", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@orpc/contract": "catalog:", "@orpc/json-schema": "catalog:", @@ -153,7 +153,7 @@ }, "apps/web": { "name": "@sofa/web", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@base-ui/react": "1.3.0", "@fontsource-variable/dm-sans": "5.2.8", @@ -215,7 +215,7 @@ }, "packages/api": { "name": "@sofa/api", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@orpc/contract": "catalog:", "zod": "catalog:", @@ -227,7 +227,7 @@ }, "packages/auth": { "name": "@sofa/auth", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@better-auth/drizzle-adapter": "1.5.6", "@better-auth/expo": "catalog:", @@ -244,7 +244,7 @@ }, "packages/config": { "name": "@sofa/config", - "version": "0.1.1", + "version": "0.1.2", "devDependencies": { "@sofa/tsconfig": "workspace:*", "@types/bun": "catalog:", @@ -253,7 +253,7 @@ }, "packages/core": { "name": "@sofa/core", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@orpc/server": "catalog:", "@sofa/api": "workspace:*", @@ -277,7 +277,7 @@ }, "packages/db": { "name": "@sofa/db", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@sofa/config": "workspace:*", "@sofa/logger": "workspace:*", @@ -292,7 +292,7 @@ }, "packages/i18n": { "name": "@sofa/i18n", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@lingui/core": "catalog:", "@lingui/react": "catalog:", @@ -310,7 +310,7 @@ }, "packages/logger": { "name": "@sofa/logger", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "pino": "10.3.1", "pino-pretty": "13.1.3", @@ -323,7 +323,7 @@ }, "packages/test": { "name": "@sofa/test", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@sofa/db": "workspace:*", "better-sqlite3": "12.8.0", @@ -338,7 +338,7 @@ }, "packages/tmdb": { "name": "@sofa/tmdb", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@sofa/logger": "workspace:*", "openapi-fetch": "0.17.0", @@ -351,10 +351,14 @@ }, "packages/tsconfig": { "name": "@sofa/tsconfig", - "version": "0.1.1", + "version": "0.1.2", }, }, "trustedDependencies": [ + "sharp", + "better-sqlite3", + "esbuild", + "msw", "@sentry/cli", ], "catalog": { @@ -383,8 +387,8 @@ "@vitest/browser-playwright": "4.1.1", "@vitest/coverage-v8": "4.1.1", "better-auth": "1.5.6", - "drizzle-kit": "1.0.0-beta.18-7eb39f0", - "drizzle-orm": "1.0.0-beta.18-7eb39f0", + "drizzle-kit": "1.0.0-beta.19", + "drizzle-orm": "1.0.0-beta.19", "react": "19.2.0", "react-dom": "19.2.0", "tailwind-merge": "3.5.0", @@ -1875,9 +1879,9 @@ "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], - "drizzle-kit": ["drizzle-kit@1.0.0-beta.18-7eb39f0", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-F3ozFBjlUr/VWHR0NIz06jM9cKmfFafoXnCDhKvwjhH91hRK/l6sxIjiDOwMxjGuoJH+/p9W8DE6juoGvn7kXA=="], + "drizzle-kit": ["drizzle-kit@1.0.0-beta.19", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-LO9cz8iipjyOROaBLmVAhu1WT4JGd8QKSc0XYhnO0FGXP5FappHII52lHcxfj2j+vVuDCTjoqeTTvW1N9NJYZQ=="], - "drizzle-orm": ["drizzle-orm@1.0.0-beta.18-7eb39f0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-piQ+esfm3krAiwXw3d8U559VdBjzA178jqazAkb0tSo7lJPZUL4SbB5uqg77ZUuCvY37hJuQ/o/gQoMX4m0E6Q=="], + "drizzle-orm": ["drizzle-orm@1.0.0-beta.19", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-wL8FoHl9NRVYdrescFWyAVY5rNuEmbY4KDqO6Qo4RTVt1rYMbz7btq5Mhg91GN3x+qeUbQ83l9QkHYd4QmVTvg=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -3199,6 +3203,8 @@ "@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@better-auth/drizzle-adapter/drizzle-orm": ["drizzle-orm@1.0.0-beta.18-7eb39f0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-piQ+esfm3krAiwXw3d8U559VdBjzA178jqazAkb0tSo7lJPZUL4SbB5uqg77ZUuCvY37hJuQ/o/gQoMX4m0E6Q=="], + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -3351,6 +3357,10 @@ "babel-plugin-syntax-hermes-parser/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], + "better-auth/drizzle-kit": ["drizzle-kit@1.0.0-beta.18-7eb39f0", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-F3ozFBjlUr/VWHR0NIz06jM9cKmfFafoXnCDhKvwjhH91hRK/l6sxIjiDOwMxjGuoJH+/p9W8DE6juoGvn7kXA=="], + + "better-auth/drizzle-orm": ["drizzle-orm@1.0.0-beta.18-7eb39f0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-piQ+esfm3krAiwXw3d8U559VdBjzA178jqazAkb0tSo7lJPZUL4SbB5uqg77ZUuCvY37hJuQ/o/gQoMX4m0E6Q=="], + "better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], diff --git a/docs/public/openapi.json b/docs/public/openapi.json index f1390cf..3d5bcc5 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -2046,7 +2046,7 @@ }, "offerType": { "type": "string", - "description": "Offer type: flatrate, rent, buy, free, ads" + "description": "Offer category: stream or purchase" }, "watchUrl": { "anyOf": [ @@ -4343,16 +4343,12 @@ "type": "string", "description": "Platform ID" }, - "tmdbProviderId": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "description": "TMDB provider ID" + "tmdbProviderIds": { + "type": "array", + "items": { + "type": "number" + }, + "description": "TMDB provider IDs" }, "name": { "type": "string", @@ -4372,7 +4368,7 @@ }, "required": [ "id", - "tmdbProviderId", + "tmdbProviderIds", "name", "logoPath" ] @@ -4861,14 +4857,12 @@ "allowReserved": true }, { - "name": "providerId", + "name": "platformId", "in": "query", "required": false, "schema": { - "type": "integer", - "minimum": -9007199254740991, - "maximum": 9007199254740991, - "description": "TMDB watch provider ID" + "type": "string", + "description": "Platform ID to filter by" }, "allowEmptyValue": true, "allowReserved": true @@ -6773,16 +6767,12 @@ "type": "string", "description": "Display name" }, - "tmdbProviderId": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "description": "TMDB provider ID (null for custom platforms)" + "tmdbProviderIds": { + "type": "array", + "items": { + "type": "number" + }, + "description": "TMDB provider IDs mapped to this platform" }, "logoPath": { "anyOf": [ @@ -6795,17 +6785,17 @@ ], "description": "Logo image path" }, - "displayOrder": { - "type": "number", - "description": "Sort order" + "isSubscription": { + "type": "boolean", + "description": "True for subscription services, false for purchase/rental stores" } }, "required": [ "id", "name", - "tmdbProviderId", + "tmdbProviderIds", "logoPath", - "displayOrder" + "isSubscription" ], "description": "A streaming platform" } diff --git a/package.json b/package.json index f308629..ff367ee 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,8 @@ "@vitest/browser-playwright": "4.1.1", "@vitest/coverage-v8": "4.1.1", "better-auth": "1.5.6", - "drizzle-kit": "1.0.0-beta.18-7eb39f0", - "drizzle-orm": "1.0.0-beta.18-7eb39f0", + "drizzle-kit": "1.0.0-beta.19", + "drizzle-orm": "1.0.0-beta.19", "react": "19.2.0", "react-dom": "19.2.0", "tailwind-merge": "3.5.0", @@ -58,7 +58,8 @@ "clean": "rm -rf .turbo \"apps/*/.turbo\" \"apps/*/node_modules\" \"apps/*/*.tsbuildinfo\" \"apps/*/dist\" \"packages/*/.turbo\" \"packages/*/node_modules\" \"packages/*/*.tsbuildinfo\" node_modules bun.lock", "generate:openapi": "bun scripts/generate-openapi-spec.ts && cd docs && bun run generate:api-docs", "i18n:extract": "lingui extract", - "i18n:claude": "bun packages/i18n/scripts/claude.ts" + "i18n:claude": "bun packages/i18n/scripts/claude.ts", + "sync:providers": "bun scripts/sync-tmdb-providers.ts" }, "devDependencies": { "@e18e/eslint-plugin": "0.3.0", @@ -82,6 +83,10 @@ }, "packageManager": "bun@1.3.11", "trustedDependencies": [ - "@sentry/cli" + "@sentry/cli", + "better-sqlite3", + "esbuild", + "msw", + "sharp" ] } diff --git a/packages/api/src/schemas.ts b/packages/api/src/schemas.ts index 1f86714..76f8e33 100644 --- a/packages/api/src/schemas.ts +++ b/packages/api/src/schemas.ts @@ -106,7 +106,7 @@ export const DiscoverInput = z .regex(/^[a-z]{2}$/) .optional() .describe("ISO 639-1 original language code"), - providerId: z.number().int().optional().describe("TMDB watch provider ID"), + platformId: z.string().optional().describe("Platform ID to filter by"), }) .merge(PageParam) .meta({ description: "Genre-based discovery filters" }); @@ -283,7 +283,7 @@ export const AvailabilityOfferSchema = z 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"), + offerType: z.string().describe("Offer category: stream or purchase"), watchUrl: z.string().nullable().describe("Direct link to watch on this provider"), isUserSubscribed: z.boolean().describe("Whether the user subscribes to this platform"), }) @@ -293,9 +293,11 @@ 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)"), + tmdbProviderIds: z.array(z.number()).describe("TMDB provider IDs mapped to this platform"), logoPath: z.string().nullable().describe("Logo image path"), - displayOrder: z.number().describe("Sort order"), + isSubscription: z + .boolean() + .describe("True for subscription services, false for purchase/rental stores"), }) .meta({ description: "A streaming platform" }); @@ -621,7 +623,7 @@ export const WatchProvidersOutput = z providers: z.array( z.object({ id: z.string().describe("Platform ID"), - tmdbProviderId: z.number().nullable().describe("TMDB provider ID"), + tmdbProviderIds: z.array(z.number()).describe("TMDB provider IDs"), name: z.string().describe("Provider display name"), logoPath: z.string().nullable().describe("Provider logo image path"), }), diff --git a/packages/core/src/integrations.ts b/packages/core/src/integrations.ts index efa9afe..45321a1 100644 --- a/packages/core/src/integrations.ts +++ b/packages/core/src/integrations.ts @@ -2,7 +2,7 @@ import { deleteIntegrationByUserAndProvider, getIntegrationByToken, getIntegrationByUserAndProvider, - getRecentEventsForIntegration, + getRecentEventsForIntegrations, getUserIntegrations, insertIntegration, regenerateIntegrationToken, @@ -38,11 +38,8 @@ function serializeIntegration(row: { export function listUserIntegrations(userId: string) { const userIntegrations = getUserIntegrations(userId); - const eventsByIntegration = new Map>(); - for (const integration of userIntegrations) { - const events = getRecentEventsForIntegration(integration.id); - eventsByIntegration.set(integration.id, events); - } + const integrationIds = userIntegrations.map((i) => i.id); + const eventsByIntegration = getRecentEventsForIntegrations(integrationIds); const result = userIntegrations.map((integration) => { const events = eventsByIntegration.get(integration.id) ?? []; diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 259f666..282eb2d 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -684,19 +684,66 @@ async function ensureEnriched( return false; } +const STREAM_TYPES = new Set(["flatrate", "free", "ads"]); +const STREAM_PRIORITY: Record = { flatrate: 0, free: 1, ads: 2 }; +const PURCHASE_PRIORITY: Record = { rent: 0, buy: 1 }; + function readAvailability( titleId: string, titleName: string, userPlatformIds?: Set, ): AvailabilityOffer[] { - return getAvailabilityOffersForTitle(titleId).map((a) => ({ - platformId: a.platformId, - providerName: a.providerName, - logoPath: tmdbImageUrl(a.logoPath, "logos"), - offerType: a.offerType, - watchUrl: generateProviderUrl(a.urlTemplate, titleName), - isUserSubscribed: userPlatformIds ? userPlatformIds.has(a.platformId) : false, - })); + const raw = getAvailabilityOffersForTitle(titleId); + + // Group by platformId to deduplicate + const byPlatform = new Map(); + for (const offer of raw) { + let list = byPlatform.get(offer.platformId); + if (!list) { + list = []; + byPlatform.set(offer.platformId, list); + } + list.push(offer); + } + + const result: AvailabilityOffer[] = []; + + for (const [platformId, offers] of byPlatform) { + const streamOffers = offers.filter((o) => STREAM_TYPES.has(o.offerType)); + const purchaseOffers = offers.filter((o) => !STREAM_TYPES.has(o.offerType)); + + // Emit one "stream" entry per platform (best offer type wins) + if (streamOffers.length > 0) { + const best = streamOffers.sort( + (a, b) => (STREAM_PRIORITY[a.offerType] ?? 99) - (STREAM_PRIORITY[b.offerType] ?? 99), + )[0]; + result.push({ + platformId, + providerName: best.providerName, + logoPath: tmdbImageUrl(best.logoPath, "logos"), + offerType: "stream", + watchUrl: generateProviderUrl(best.urlTemplate, titleName), + isUserSubscribed: userPlatformIds ? userPlatformIds.has(platformId) : false, + }); + } + + // Emit one "purchase" entry per platform (rent preferred over buy) + if (purchaseOffers.length > 0) { + const best = purchaseOffers.sort( + (a, b) => (PURCHASE_PRIORITY[a.offerType] ?? 99) - (PURCHASE_PRIORITY[b.offerType] ?? 99), + )[0]; + result.push({ + platformId, + providerName: best.providerName, + logoPath: tmdbImageUrl(best.logoPath, "logos"), + offerType: "purchase", + watchUrl: generateProviderUrl(best.urlTemplate, titleName), + isUserSubscribed: userPlatformIds ? userPlatformIds.has(platformId) : false, + }); + } + } + + return result; } export async function getOrFetchTitle( diff --git a/packages/core/src/platforms.ts b/packages/core/src/platforms.ts index a2f199b..d0a6e25 100644 --- a/packages/core/src/platforms.ts +++ b/packages/core/src/platforms.ts @@ -1,5 +1,7 @@ import { getAllPlatforms, + getTmdbProviderIdsByPlatformIds, + getTmdbProviderIdsForPlatform, getUserPlatformIds, getUserPlatforms, hasUserPlatforms, @@ -29,3 +31,11 @@ export function updateUserPlatforms(userId: string, platformIds: string[]): void export function hasUserSetPlatforms(userId: string): boolean { return hasUserPlatforms(userId); } + +export function getPlatformTmdbIds(platformId: string): number[] { + return getTmdbProviderIdsForPlatform(platformId); +} + +export function getPlatformTmdbIdMap(platformIds: string[]): Map { + return getTmdbProviderIdsByPlatformIds(platformIds); +} diff --git a/packages/core/src/system-health.ts b/packages/core/src/system-health.ts index a884de9..dbc3f38 100644 --- a/packages/core/src/system-health.ts +++ b/packages/core/src/system-health.ts @@ -2,8 +2,7 @@ import { access, constants, readdir } from "node:fs/promises"; import path from "node:path"; import { CACHE_DIR, DATA_DIR, DATABASE_URL, TMDB_API_BASE_URL } from "@sofa/config"; -import { getLatestCronRun, getTableCounts } from "@sofa/db/queries/system-health"; -import type { cronRuns } from "@sofa/db/schema"; +import { getLatestCronRuns, getTableCounts } from "@sofa/db/queries/system-health"; import { listBackups } from "./backup"; import { imageCacheEnabled } from "./image-cache"; @@ -153,12 +152,7 @@ function getJobsHealth(): SystemHealthData["jobs"] { const schedules = _getJobSchedules?.() ?? []; const scheduleMap = new Map(schedules.map((s) => [s.jobName, s])); - // Fetch only the latest cron run per job (index-optimized LIMIT 1 each) - const latestByJob = new Map(); - for (const jobName of JOB_NAMES) { - const latest = getLatestCronRun(jobName); - if (latest) latestByJob.set(jobName, latest); - } + const latestByJob = getLatestCronRuns([...JOB_NAMES]); return JOB_NAMES.map((jobName) => { const latest = latestByJob.get(jobName); diff --git a/packages/core/src/tracking.ts b/packages/core/src/tracking.ts index 717ba0a..60aa448 100644 --- a/packages/core/src/tracking.ts +++ b/packages/core/src/tracking.ts @@ -11,9 +11,10 @@ import { getAllEpisodeIdsForTitle, getEpisodeProgressByTitleIds as getEpisodeProgressByTitleIdsQuery, getEpisodeTitleId, + getEpisodeTitleIds, getExistingEpisodeWatchIds, getSeasonById, - getSeasonEpisodes, + getSeasonEpisodeIds, getTitleStatus, getUserStatusesByTitleIds as getUserStatusesByTitleIdsQuery, getUserTitleInfo as getUserTitleInfoQuery, @@ -91,11 +92,8 @@ export function logEpisodeWatchBatch( batchInsertEpisodeWatchesTransaction(userId, episodeIds, source, watchedAt); // Auto-set title status to in_progress for affected titles - const titleIds = new Set(); - for (const episodeId of episodeIds) { - const titleId = getEpisodeTitleId(episodeId); - if (titleId) titleIds.add(titleId); - } + const episodeTitleMap = getEpisodeTitleIds(episodeIds); + const titleIds = new Set(episodeTitleMap.values()); for (const titleId of titleIds) { const existing = getTitleStatus(userId, titleId); if (!existing || existing.status === "watchlist") { @@ -140,9 +138,7 @@ export function unwatchEpisode(userId: string, episodeId: string) { } export function unwatchSeason(userId: string, seasonId: string) { - const seasonEps = getSeasonEpisodes(seasonId); - - const epIds = seasonEps.map((ep) => ep.id); + const epIds = getSeasonEpisodeIds(seasonId); if (epIds.length > 0) { deleteEpisodeWatches(userId, epIds); } @@ -271,9 +267,5 @@ export function quickAddTitle( } export function watchSeason(userId: string, seasonId: string): void { - const seasonEps = getSeasonEpisodes(seasonId); - logEpisodeWatchBatch( - userId, - seasonEps.map((ep) => ep.id), - ); + logEpisodeWatchBatch(userId, getSeasonEpisodeIds(seasonId)); } diff --git a/packages/core/src/webhooks.ts b/packages/core/src/webhooks.ts index 817aea3..2bb383c 100644 --- a/packages/core/src/webhooks.ts +++ b/packages/core/src/webhooks.ts @@ -2,8 +2,7 @@ import { findEpisodeBySeasonAndNumber, findSeasonByTitleAndNumber } from "@sofa/ import { getRecentEpisodeWatch, getRecentMovieWatch, - insertIntegrationEvent, - updateIntegrationLastEvent, + insertIntegrationEventTransaction, } from "@sofa/db/queries/webhooks"; import { createLogger } from "@sofa/logger"; import { getTvDetails } from "@sofa/tmdb/client"; @@ -189,22 +188,23 @@ function logEvent( status: "success" | "ignored" | "error", errorMessage?: string, ) { - insertIntegrationEvent({ - integrationId: connectionId, - eventType: - event?.provider === "plex" - ? "media.scrobble" - : event?.provider === "emby" - ? "playback.stop" - : "PlaybackStop", - mediaType: event?.mediaType ?? null, - mediaTitle: event?.title ?? null, - status, - errorMessage: errorMessage ?? null, - receivedAt: new Date(), - }); - - updateIntegrationLastEvent(connectionId); + insertIntegrationEventTransaction( + { + integrationId: connectionId, + eventType: + event?.provider === "plex" + ? "media.scrobble" + : event?.provider === "emby" + ? "playback.stop" + : "PlaybackStop", + mediaType: event?.mediaType ?? null, + mediaTitle: event?.title ?? null, + status, + errorMessage: errorMessage ?? null, + receivedAt: new Date(), + }, + connectionId, + ); } // ─── Main Processing ──────────────────────────────────────────────── diff --git a/packages/core/test/upcoming.test.ts b/packages/core/test/upcoming.test.ts index e740307..450d3e1 100644 --- a/packages/core/test/upcoming.test.ts +++ b/packages/core/test/upcoming.test.ts @@ -294,7 +294,35 @@ describe("streaming provider", () => { }); }); - test("returns null when no flatrate provider exists", () => { + test("attaches ads-supported streaming provider", () => { + const tomorrow = daysFromNow(1); + insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] }); + insertStatus("user-1", "tv-1", "in_progress"); + const pId = insertPlatform({ id: "p-tubi", name: "Tubi", tmdbProviderId: 73 }); + insertTitleAvailability("tv-1", pId, { offerType: "ads" }); + + const result = getUpcomingFeed("user-1", { days: 7 }); + expect(result.items[0].streamingProvider).toEqual({ + platformId: "p-tubi", + providerName: "Tubi", + logoPath: "/logo.png", + }); + }); + + test("prefers flatrate over ads when both exist", () => { + const tomorrow = daysFromNow(1); + insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] }); + insertStatus("user-1", "tv-1", "in_progress"); + const pAds = insertPlatform({ id: "p-tubi", name: "Tubi", tmdbProviderId: 73 }); + const pFlat = insertPlatform({ id: "p-netflix", name: "Netflix", tmdbProviderId: 8 }); + insertTitleAvailability("tv-1", pAds, { offerType: "ads" }); + insertTitleAvailability("tv-1", pFlat, { offerType: "flatrate" }); + + const result = getUpcomingFeed("user-1", { days: 7 }); + expect(result.items[0].streamingProvider!.platformId).toBe("p-netflix"); + }); + + test("returns null when only purchase providers exist", () => { const tomorrow = daysFromNow(1); insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] }); insertStatus("user-1", "tv-1", "in_progress"); diff --git a/packages/db/drizzle/20260324210721_small_prowler/migration.sql b/packages/db/drizzle/20260324210721_small_prowler/migration.sql new file mode 100644 index 0000000..06c0054 --- /dev/null +++ b/packages/db/drizzle/20260324210721_small_prowler/migration.sql @@ -0,0 +1,13 @@ +CREATE TABLE `platformTmdbIds` ( + `platformId` text NOT NULL, + `tmdbProviderId` integer NOT NULL, + CONSTRAINT `fk_platformTmdbIds_platformId_platforms_id_fk` FOREIGN KEY (`platformId`) REFERENCES `platforms`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +INSERT INTO `platformTmdbIds` (`platformId`, `tmdbProviderId`) +SELECT `id`, `tmdbProviderId` FROM `platforms` WHERE `tmdbProviderId` IS NOT NULL; +--> statement-breakpoint +DROP INDEX IF EXISTS `platforms_tmdbProviderId_unique`;--> statement-breakpoint +CREATE UNIQUE INDEX `platformTmdbIds_tmdbProviderId_unique` ON `platformTmdbIds` (`tmdbProviderId`);--> statement-breakpoint +CREATE INDEX `platformTmdbIds_platformId` ON `platformTmdbIds` (`platformId`);--> statement-breakpoint +ALTER TABLE `platforms` DROP COLUMN `tmdbProviderId`; diff --git a/packages/db/drizzle/20260324210721_small_prowler/snapshot.json b/packages/db/drizzle/20260324210721_small_prowler/snapshot.json new file mode 100644 index 0000000..e974267 --- /dev/null +++ b/packages/db/drizzle/20260324210721_small_prowler/snapshot.json @@ -0,0 +1,3420 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "364cbbc8-2e6e-4ebb-9e52-4fea9e88b994", + "prevIds": [ + "09684273-fbee-43c5-ac9c-95a0882e6302" + ], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "appSettings", + "entityType": "tables" + }, + { + "name": "cronRuns", + "entityType": "tables" + }, + { + "name": "episodes", + "entityType": "tables" + }, + { + "name": "genres", + "entityType": "tables" + }, + { + "name": "importJobs", + "entityType": "tables" + }, + { + "name": "integrationEvents", + "entityType": "tables" + }, + { + "name": "integrations", + "entityType": "tables" + }, + { + "name": "personFilmography", + "entityType": "tables" + }, + { + "name": "persons", + "entityType": "tables" + }, + { + "name": "platformTmdbIds", + "entityType": "tables" + }, + { + "name": "platforms", + "entityType": "tables" + }, + { + "name": "seasons", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "titleAvailability", + "entityType": "tables" + }, + { + "name": "titleCast", + "entityType": "tables" + }, + { + "name": "titleGenres", + "entityType": "tables" + }, + { + "name": "titleRecommendations", + "entityType": "tables" + }, + { + "name": "titles", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "userEpisodeWatches", + "entityType": "tables" + }, + { + "name": "userMovieWatches", + "entityType": "tables" + }, + { + "name": "userPlatforms", + "entityType": "tables" + }, + { + "name": "userRatings", + "entityType": "tables" + }, + { + "name": "userTitleStatus", + "entityType": "tables" + }, + { + "name": "verification", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accountId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "providerId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accessToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refreshToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "idToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accessTokenExpiresAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refreshTokenExpiresAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scope", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "appSettings" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "appSettings" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "jobName", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "startedAt", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "finishedAt", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "durationMs", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "errorMessage", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seasonId", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeNumber", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stillPath", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stillThumbHash", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "airDate", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "runtimeMinutes", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "genres" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "genres" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "payload", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "importWatches", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "importWatchlist", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "importRatings", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "totalItems", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "processedItems", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "importedCount", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "skippedCount", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "failedCount", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "currentMessage", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "errors", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "warnings", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "startedAt", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "finishedAt", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "integrationId", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "eventType", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mediaType", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mediaTitle", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "errorMessage", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "receivedAt", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastEventAt", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "personId", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "character", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Acting'", + "generated": null, + "name": "department", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "job", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "displayOrder", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "persons" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tmdbId", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "biography", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "birthday", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "deathday", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "placeOfBirth", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "profilePath", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "profileThumbHash", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "knownForDepartment", + "entityType": "columns", + "table": "persons" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "popularity", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "imdbId", + "entityType": "columns", + "table": "persons" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "persons" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "filmographyLastFetchedAt", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "platformId", + "entityType": "columns", + "table": "platformTmdbIds" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tmdbProviderId", + "entityType": "columns", + "table": "platformTmdbIds" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "platforms" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "platforms" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "logoPath", + "entityType": "columns", + "table": "platforms" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "urlTemplate", + "entityType": "columns", + "table": "platforms" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "displayOrder", + "entityType": "columns", + "table": "platforms" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seasonNumber", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterPath", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterThumbHash", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "airDate", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expiresAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ipAddress", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userAgent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "impersonatedBy", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleAvailability" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "platformId", + "entityType": "columns", + "table": "titleAvailability" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "offerType", + "entityType": "columns", + "table": "titleAvailability" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'US'", + "generated": null, + "name": "region", + "entityType": "columns", + "table": "titleAvailability" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titleAvailability" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "personId", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "character", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Acting'", + "generated": null, + "name": "department", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "job", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "displayOrder", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeCount", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleGenres" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "genreId", + "entityType": "columns", + "table": "titleGenres" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "recommendedTitleId", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "rank", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tmdbId", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tvdbId", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "originalTitle", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "releaseDate", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "firstAirDate", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterPath", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterThumbHash", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backdropPath", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backdropThumbHash", + "entityType": "columns", + "table": "titles" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "popularity", + "entityType": "columns", + "table": "titles" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "voteAverage", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "voteCount", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "contentRating", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "imdbId", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "originalLanguage", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "runtimeMinutes", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "colorPalette", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "trailerVideoKey", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "emailVerified", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "image", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'user'", + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "banned", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "banReason", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "banExpires", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeId", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "watchedAt", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'manual'", + "generated": null, + "name": "source", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "watchedAt", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'manual'", + "generated": null, + "name": "source", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userPlatforms" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "platformId", + "entityType": "columns", + "table": "userPlatforms" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ratingStars", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ratedAt", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "addedAt", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "identifier", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expiresAt", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "verification" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_account_userId_user_id_fk", + "entityType": "fks", + "table": "account" + }, + { + "columns": [ + "seasonId" + ], + "tableTo": "seasons", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_episodes_seasonId_seasons_id_fk", + "entityType": "fks", + "table": "episodes" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_importJobs_userId_user_id_fk", + "entityType": "fks", + "table": "importJobs" + }, + { + "columns": [ + "integrationId" + ], + "tableTo": "integrations", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_integrationEvents_integrationId_integrations_id_fk", + "entityType": "fks", + "table": "integrationEvents" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_integrations_userId_user_id_fk", + "entityType": "fks", + "table": "integrations" + }, + { + "columns": [ + "personId" + ], + "tableTo": "persons", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_personFilmography_personId_persons_id_fk", + "entityType": "fks", + "table": "personFilmography" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_personFilmography_titleId_titles_id_fk", + "entityType": "fks", + "table": "personFilmography" + }, + { + "columns": [ + "platformId" + ], + "tableTo": "platforms", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_platformTmdbIds_platformId_platforms_id_fk", + "entityType": "fks", + "table": "platformTmdbIds" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_seasons_titleId_titles_id_fk", + "entityType": "fks", + "table": "seasons" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_userId_user_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleAvailability_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleAvailability" + }, + { + "columns": [ + "platformId" + ], + "tableTo": "platforms", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleAvailability_platformId_platforms_id_fk", + "entityType": "fks", + "table": "titleAvailability" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleCast_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleCast" + }, + { + "columns": [ + "personId" + ], + "tableTo": "persons", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleCast_personId_persons_id_fk", + "entityType": "fks", + "table": "titleCast" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleGenres_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleGenres" + }, + { + "columns": [ + "genreId" + ], + "tableTo": "genres", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleGenres_genreId_genres_id_fk", + "entityType": "fks", + "table": "titleGenres" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleRecommendations_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleRecommendations" + }, + { + "columns": [ + "recommendedTitleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleRecommendations_recommendedTitleId_titles_id_fk", + "entityType": "fks", + "table": "titleRecommendations" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userEpisodeWatches_userId_user_id_fk", + "entityType": "fks", + "table": "userEpisodeWatches" + }, + { + "columns": [ + "episodeId" + ], + "tableTo": "episodes", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userEpisodeWatches_episodeId_episodes_id_fk", + "entityType": "fks", + "table": "userEpisodeWatches" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userMovieWatches_userId_user_id_fk", + "entityType": "fks", + "table": "userMovieWatches" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userMovieWatches_titleId_titles_id_fk", + "entityType": "fks", + "table": "userMovieWatches" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userPlatforms_userId_user_id_fk", + "entityType": "fks", + "table": "userPlatforms" + }, + { + "columns": [ + "platformId" + ], + "tableTo": "platforms", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userPlatforms_platformId_platforms_id_fk", + "entityType": "fks", + "table": "userPlatforms" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userRatings_userId_user_id_fk", + "entityType": "fks", + "table": "userRatings" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userRatings_titleId_titles_id_fk", + "entityType": "fks", + "table": "userRatings" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userTitleStatus_userId_user_id_fk", + "entityType": "fks", + "table": "userTitleStatus" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userTitleStatus_titleId_titles_id_fk", + "entityType": "fks", + "table": "userTitleStatus" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "appSettings_pk", + "table": "appSettings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cronRuns_pk", + "table": "cronRuns", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "episodes_pk", + "table": "episodes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "genres_pk", + "table": "genres", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "importJobs_pk", + "table": "importJobs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "integrationEvents_pk", + "table": "integrationEvents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "integrations_pk", + "table": "integrations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "personFilmography_pk", + "table": "personFilmography", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "persons_pk", + "table": "persons", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "platforms_pk", + "table": "platforms", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "seasons_pk", + "table": "seasons", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "titleCast_pk", + "table": "titleCast", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "titles_pk", + "table": "titles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "user_pk", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userEpisodeWatches_pk", + "table": "userEpisodeWatches", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userMovieWatches_pk", + "table": "userMovieWatches", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "verification_pk", + "table": "verification", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "jobName", + "isExpression": false + }, + { + "value": "startedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "cronRuns_jobName_startedAt", + "entityType": "indexes", + "table": "cronRuns" + }, + { + "columns": [ + { + "value": "seasonId", + "isExpression": false + }, + { + "value": "episodeNumber", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "episodes_seasonId_episodeNumber", + "entityType": "indexes", + "table": "episodes" + }, + { + "columns": [ + { + "value": "airDate", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "episodes_airDate", + "entityType": "indexes", + "table": "episodes" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "createdAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "importJobs_userId_createdAt", + "entityType": "indexes", + "table": "importJobs" + }, + { + "columns": [ + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "importJobs_status", + "entityType": "indexes", + "table": "importJobs" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + } + ], + "isUnique": true, + "where": "\"importJobs\".\"status\" in ('pending', 'running')", + "origin": "manual", + "name": "importJobs_active_user", + "entityType": "indexes", + "table": "importJobs" + }, + { + "columns": [ + { + "value": "integrationId", + "isExpression": false + }, + { + "value": "receivedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "integrationEvents_integrationId_receivedAt", + "entityType": "indexes", + "table": "integrationEvents" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "integrations_userId_provider", + "entityType": "indexes", + "table": "integrations" + }, + { + "columns": [ + { + "value": "token", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "integrations_token", + "entityType": "indexes", + "table": "integrations" + }, + { + "columns": [ + { + "value": "personId", + "isExpression": false + }, + { + "value": "displayOrder", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "personFilmography_personId_displayOrder", + "entityType": "indexes", + "table": "personFilmography" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "personFilmography_titleId", + "entityType": "indexes", + "table": "personFilmography" + }, + { + "columns": [ + { + "value": "tmdbId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "persons_tmdbId_unique", + "entityType": "indexes", + "table": "persons" + }, + { + "columns": [ + { + "value": "name", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "persons_name", + "entityType": "indexes", + "table": "persons" + }, + { + "columns": [ + { + "value": "tmdbProviderId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "platformTmdbIds_tmdbProviderId_unique", + "entityType": "indexes", + "table": "platformTmdbIds" + }, + { + "columns": [ + { + "value": "platformId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "platformTmdbIds_platformId", + "entityType": "indexes", + "table": "platformTmdbIds" + }, + { + "columns": [ + { + "value": "displayOrder", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "platforms_displayOrder", + "entityType": "indexes", + "table": "platforms" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "seasonNumber", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "seasons_titleId_seasonNumber", + "entityType": "indexes", + "table": "seasons" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "platformId", + "isExpression": false + }, + { + "value": "offerType", + "isExpression": false + }, + { + "value": "region", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleAvailability_unique", + "entityType": "indexes", + "table": "titleAvailability" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleAvailability_titleId", + "entityType": "indexes", + "table": "titleAvailability" + }, + { + "columns": [ + { + "value": "platformId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleAvailability_platformId", + "entityType": "indexes", + "table": "titleAvailability" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "personId", + "isExpression": false + }, + { + "value": "department", + "isExpression": false + }, + { + "value": "character", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleCast_unique", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "displayOrder", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleCast_titleId_displayOrder", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "personId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleCast_personId", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "genreId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleGenres_titleId_genreId", + "entityType": "indexes", + "table": "titleGenres" + }, + { + "columns": [ + { + "value": "genreId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleGenres_genreId", + "entityType": "indexes", + "table": "titleGenres" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "recommendedTitleId", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleRecommendations_unique", + "entityType": "indexes", + "table": "titleRecommendations" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "rank", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleRecommendations_titleId_rank", + "entityType": "indexes", + "table": "titleRecommendations" + }, + { + "columns": [ + { + "value": "tmdbId", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titles_tmdbId_type_unique", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + }, + { + "value": "releaseDate", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_type_releaseDate", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + }, + { + "value": "firstAirDate", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_type_firstAirDate", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "lastFetchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_lastFetchedAt", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + }, + { + "value": "lastFetchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_type_status_lastFetchedAt", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "watchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_userId_watchedAt", + "entityType": "indexes", + "table": "userEpisodeWatches" + }, + { + "columns": [ + { + "value": "episodeId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_episodeId", + "entityType": "indexes", + "table": "userEpisodeWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "episodeId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_userId_episodeId", + "entityType": "indexes", + "table": "userEpisodeWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "watchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_userId_watchedAt", + "entityType": "indexes", + "table": "userMovieWatches" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_titleId", + "entityType": "indexes", + "table": "userMovieWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_userId_titleId", + "entityType": "indexes", + "table": "userMovieWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "platformId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "userPlatforms_userId_platformId", + "entityType": "indexes", + "table": "userPlatforms" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userPlatforms_userId", + "entityType": "indexes", + "table": "userPlatforms" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "userRatings_userId_titleId", + "entityType": "indexes", + "table": "userRatings" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "userTitleStatus_userId_titleId", + "entityType": "indexes", + "table": "userTitleStatus" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userTitleStatus_userId_status", + "entityType": "indexes", + "table": "userTitleStatus" + }, + { + "columns": [ + "token" + ], + "nameExplicit": false, + "name": "session_token_unique", + "entityType": "uniques", + "table": "session" + }, + { + "columns": [ + "email" + ], + "nameExplicit": false, + "name": "user_email_unique", + "entityType": "uniques", + "table": "user" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/db/drizzle/20260324223903_secret_rhodey/migration.sql b/packages/db/drizzle/20260324223903_secret_rhodey/migration.sql new file mode 100644 index 0000000..b70c2c9 --- /dev/null +++ b/packages/db/drizzle/20260324223903_secret_rhodey/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE `platforms` ADD `isSubscription` integer DEFAULT true NOT NULL;--> statement-breakpoint +DROP INDEX IF EXISTS `platforms_displayOrder`;--> statement-breakpoint +ALTER TABLE `platforms` DROP COLUMN `displayOrder`; \ No newline at end of file diff --git a/packages/db/drizzle/20260324223903_secret_rhodey/snapshot.json b/packages/db/drizzle/20260324223903_secret_rhodey/snapshot.json new file mode 100644 index 0000000..518c2c9 --- /dev/null +++ b/packages/db/drizzle/20260324223903_secret_rhodey/snapshot.json @@ -0,0 +1,3406 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "f50d9b53-3ac7-4fd2-95e1-3f8480b5f636", + "prevIds": [ + "364cbbc8-2e6e-4ebb-9e52-4fea9e88b994" + ], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "appSettings", + "entityType": "tables" + }, + { + "name": "cronRuns", + "entityType": "tables" + }, + { + "name": "episodes", + "entityType": "tables" + }, + { + "name": "genres", + "entityType": "tables" + }, + { + "name": "importJobs", + "entityType": "tables" + }, + { + "name": "integrationEvents", + "entityType": "tables" + }, + { + "name": "integrations", + "entityType": "tables" + }, + { + "name": "personFilmography", + "entityType": "tables" + }, + { + "name": "persons", + "entityType": "tables" + }, + { + "name": "platformTmdbIds", + "entityType": "tables" + }, + { + "name": "platforms", + "entityType": "tables" + }, + { + "name": "seasons", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "titleAvailability", + "entityType": "tables" + }, + { + "name": "titleCast", + "entityType": "tables" + }, + { + "name": "titleGenres", + "entityType": "tables" + }, + { + "name": "titleRecommendations", + "entityType": "tables" + }, + { + "name": "titles", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "userEpisodeWatches", + "entityType": "tables" + }, + { + "name": "userMovieWatches", + "entityType": "tables" + }, + { + "name": "userPlatforms", + "entityType": "tables" + }, + { + "name": "userRatings", + "entityType": "tables" + }, + { + "name": "userTitleStatus", + "entityType": "tables" + }, + { + "name": "verification", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accountId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "providerId", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accessToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refreshToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "idToken", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "accessTokenExpiresAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refreshTokenExpiresAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "scope", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "password", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "appSettings" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "appSettings" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "jobName", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "startedAt", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "finishedAt", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "durationMs", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "errorMessage", + "entityType": "columns", + "table": "cronRuns" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seasonId", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeNumber", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stillPath", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stillThumbHash", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "airDate", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "runtimeMinutes", + "entityType": "columns", + "table": "episodes" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "genres" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "genres" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "payload", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "importWatches", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "importWatchlist", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "importRatings", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "totalItems", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "processedItems", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "importedCount", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "skippedCount", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "failedCount", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "currentMessage", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "errors", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "warnings", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "startedAt", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "finishedAt", + "entityType": "columns", + "table": "importJobs" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "integrationId", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "eventType", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mediaType", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "mediaTitle", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "errorMessage", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "receivedAt", + "entityType": "columns", + "table": "integrationEvents" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "enabled", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastEventAt", + "entityType": "columns", + "table": "integrations" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "personId", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "character", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Acting'", + "generated": null, + "name": "department", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "job", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "displayOrder", + "entityType": "columns", + "table": "personFilmography" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "persons" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tmdbId", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "biography", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "birthday", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "deathday", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "placeOfBirth", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "profilePath", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "profileThumbHash", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "knownForDepartment", + "entityType": "columns", + "table": "persons" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "popularity", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "imdbId", + "entityType": "columns", + "table": "persons" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "persons" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "filmographyLastFetchedAt", + "entityType": "columns", + "table": "persons" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "platformId", + "entityType": "columns", + "table": "platformTmdbIds" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tmdbProviderId", + "entityType": "columns", + "table": "platformTmdbIds" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "platforms" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "platforms" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "logoPath", + "entityType": "columns", + "table": "platforms" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "urlTemplate", + "entityType": "columns", + "table": "platforms" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "true", + "generated": null, + "name": "isSubscription", + "entityType": "columns", + "table": "platforms" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seasonNumber", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterPath", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterThumbHash", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "airDate", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "seasons" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expiresAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ipAddress", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userAgent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "impersonatedBy", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleAvailability" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "platformId", + "entityType": "columns", + "table": "titleAvailability" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "offerType", + "entityType": "columns", + "table": "titleAvailability" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'US'", + "generated": null, + "name": "region", + "entityType": "columns", + "table": "titleAvailability" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titleAvailability" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "personId", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "character", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'Acting'", + "generated": null, + "name": "department", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "job", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "displayOrder", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeCount", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titleCast" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleGenres" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "genreId", + "entityType": "columns", + "table": "titleGenres" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "recommendedTitleId", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "source", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "rank", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titleRecommendations" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tmdbId", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tvdbId", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "originalTitle", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "overview", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "releaseDate", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "firstAirDate", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterPath", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "posterThumbHash", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backdropPath", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "backdropThumbHash", + "entityType": "columns", + "table": "titles" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "popularity", + "entityType": "columns", + "table": "titles" + }, + { + "type": "real", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "voteAverage", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "voteCount", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "contentRating", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "imdbId", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "originalLanguage", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "runtimeMinutes", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "colorPalette", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "trailerVideoKey", + "entityType": "columns", + "table": "titles" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "lastFetchedAt", + "entityType": "columns", + "table": "titles" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "emailVerified", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "image", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": "'user'", + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "banned", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "banReason", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "banExpires", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "user" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "user" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "episodeId", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "watchedAt", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'manual'", + "generated": null, + "name": "source", + "entityType": "columns", + "table": "userEpisodeWatches" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "watchedAt", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'manual'", + "generated": null, + "name": "source", + "entityType": "columns", + "table": "userMovieWatches" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userPlatforms" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "platformId", + "entityType": "columns", + "table": "userPlatforms" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ratingStars", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "ratedAt", + "entityType": "columns", + "table": "userRatings" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "userId", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "titleId", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "addedAt", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "userTitleStatus" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "identifier", + "entityType": "columns", + "table": "verification" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expiresAt", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "createdAt", + "entityType": "columns", + "table": "verification" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updatedAt", + "entityType": "columns", + "table": "verification" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_account_userId_user_id_fk", + "entityType": "fks", + "table": "account" + }, + { + "columns": [ + "seasonId" + ], + "tableTo": "seasons", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_episodes_seasonId_seasons_id_fk", + "entityType": "fks", + "table": "episodes" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_importJobs_userId_user_id_fk", + "entityType": "fks", + "table": "importJobs" + }, + { + "columns": [ + "integrationId" + ], + "tableTo": "integrations", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_integrationEvents_integrationId_integrations_id_fk", + "entityType": "fks", + "table": "integrationEvents" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_integrations_userId_user_id_fk", + "entityType": "fks", + "table": "integrations" + }, + { + "columns": [ + "personId" + ], + "tableTo": "persons", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_personFilmography_personId_persons_id_fk", + "entityType": "fks", + "table": "personFilmography" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_personFilmography_titleId_titles_id_fk", + "entityType": "fks", + "table": "personFilmography" + }, + { + "columns": [ + "platformId" + ], + "tableTo": "platforms", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_platformTmdbIds_platformId_platforms_id_fk", + "entityType": "fks", + "table": "platformTmdbIds" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_seasons_titleId_titles_id_fk", + "entityType": "fks", + "table": "seasons" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_userId_user_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleAvailability_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleAvailability" + }, + { + "columns": [ + "platformId" + ], + "tableTo": "platforms", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleAvailability_platformId_platforms_id_fk", + "entityType": "fks", + "table": "titleAvailability" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleCast_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleCast" + }, + { + "columns": [ + "personId" + ], + "tableTo": "persons", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleCast_personId_persons_id_fk", + "entityType": "fks", + "table": "titleCast" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleGenres_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleGenres" + }, + { + "columns": [ + "genreId" + ], + "tableTo": "genres", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleGenres_genreId_genres_id_fk", + "entityType": "fks", + "table": "titleGenres" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleRecommendations_titleId_titles_id_fk", + "entityType": "fks", + "table": "titleRecommendations" + }, + { + "columns": [ + "recommendedTitleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_titleRecommendations_recommendedTitleId_titles_id_fk", + "entityType": "fks", + "table": "titleRecommendations" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userEpisodeWatches_userId_user_id_fk", + "entityType": "fks", + "table": "userEpisodeWatches" + }, + { + "columns": [ + "episodeId" + ], + "tableTo": "episodes", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userEpisodeWatches_episodeId_episodes_id_fk", + "entityType": "fks", + "table": "userEpisodeWatches" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userMovieWatches_userId_user_id_fk", + "entityType": "fks", + "table": "userMovieWatches" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userMovieWatches_titleId_titles_id_fk", + "entityType": "fks", + "table": "userMovieWatches" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userPlatforms_userId_user_id_fk", + "entityType": "fks", + "table": "userPlatforms" + }, + { + "columns": [ + "platformId" + ], + "tableTo": "platforms", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userPlatforms_platformId_platforms_id_fk", + "entityType": "fks", + "table": "userPlatforms" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userRatings_userId_user_id_fk", + "entityType": "fks", + "table": "userRatings" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userRatings_titleId_titles_id_fk", + "entityType": "fks", + "table": "userRatings" + }, + { + "columns": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userTitleStatus_userId_user_id_fk", + "entityType": "fks", + "table": "userTitleStatus" + }, + { + "columns": [ + "titleId" + ], + "tableTo": "titles", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_userTitleStatus_titleId_titles_id_fk", + "entityType": "fks", + "table": "userTitleStatus" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "key" + ], + "nameExplicit": false, + "name": "appSettings_pk", + "table": "appSettings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cronRuns_pk", + "table": "cronRuns", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "episodes_pk", + "table": "episodes", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "genres_pk", + "table": "genres", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "importJobs_pk", + "table": "importJobs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "integrationEvents_pk", + "table": "integrationEvents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "integrations_pk", + "table": "integrations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "personFilmography_pk", + "table": "personFilmography", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "persons_pk", + "table": "persons", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "platforms_pk", + "table": "platforms", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "seasons_pk", + "table": "seasons", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "titleCast_pk", + "table": "titleCast", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "titles_pk", + "table": "titles", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "user_pk", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userEpisodeWatches_pk", + "table": "userEpisodeWatches", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "userMovieWatches_pk", + "table": "userMovieWatches", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "verification_pk", + "table": "verification", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "jobName", + "isExpression": false + }, + { + "value": "startedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "cronRuns_jobName_startedAt", + "entityType": "indexes", + "table": "cronRuns" + }, + { + "columns": [ + { + "value": "seasonId", + "isExpression": false + }, + { + "value": "episodeNumber", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "episodes_seasonId_episodeNumber", + "entityType": "indexes", + "table": "episodes" + }, + { + "columns": [ + { + "value": "airDate", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "episodes_airDate", + "entityType": "indexes", + "table": "episodes" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "createdAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "importJobs_userId_createdAt", + "entityType": "indexes", + "table": "importJobs" + }, + { + "columns": [ + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "importJobs_status", + "entityType": "indexes", + "table": "importJobs" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + } + ], + "isUnique": true, + "where": "\"importJobs\".\"status\" in ('pending', 'running')", + "origin": "manual", + "name": "importJobs_active_user", + "entityType": "indexes", + "table": "importJobs" + }, + { + "columns": [ + { + "value": "integrationId", + "isExpression": false + }, + { + "value": "receivedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "integrationEvents_integrationId_receivedAt", + "entityType": "indexes", + "table": "integrationEvents" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "integrations_userId_provider", + "entityType": "indexes", + "table": "integrations" + }, + { + "columns": [ + { + "value": "token", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "integrations_token", + "entityType": "indexes", + "table": "integrations" + }, + { + "columns": [ + { + "value": "personId", + "isExpression": false + }, + { + "value": "displayOrder", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "personFilmography_personId_displayOrder", + "entityType": "indexes", + "table": "personFilmography" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "personFilmography_titleId", + "entityType": "indexes", + "table": "personFilmography" + }, + { + "columns": [ + { + "value": "tmdbId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "persons_tmdbId_unique", + "entityType": "indexes", + "table": "persons" + }, + { + "columns": [ + { + "value": "name", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "persons_name", + "entityType": "indexes", + "table": "persons" + }, + { + "columns": [ + { + "value": "tmdbProviderId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "platformTmdbIds_tmdbProviderId_unique", + "entityType": "indexes", + "table": "platformTmdbIds" + }, + { + "columns": [ + { + "value": "platformId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "platformTmdbIds_platformId", + "entityType": "indexes", + "table": "platformTmdbIds" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "seasonNumber", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "seasons_titleId_seasonNumber", + "entityType": "indexes", + "table": "seasons" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "platformId", + "isExpression": false + }, + { + "value": "offerType", + "isExpression": false + }, + { + "value": "region", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleAvailability_unique", + "entityType": "indexes", + "table": "titleAvailability" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleAvailability_titleId", + "entityType": "indexes", + "table": "titleAvailability" + }, + { + "columns": [ + { + "value": "platformId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleAvailability_platformId", + "entityType": "indexes", + "table": "titleAvailability" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "personId", + "isExpression": false + }, + { + "value": "department", + "isExpression": false + }, + { + "value": "character", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleCast_unique", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "displayOrder", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleCast_titleId_displayOrder", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "personId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleCast_personId", + "entityType": "indexes", + "table": "titleCast" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "genreId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleGenres_titleId_genreId", + "entityType": "indexes", + "table": "titleGenres" + }, + { + "columns": [ + { + "value": "genreId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleGenres_genreId", + "entityType": "indexes", + "table": "titleGenres" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "recommendedTitleId", + "isExpression": false + }, + { + "value": "source", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titleRecommendations_unique", + "entityType": "indexes", + "table": "titleRecommendations" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + }, + { + "value": "rank", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titleRecommendations_titleId_rank", + "entityType": "indexes", + "table": "titleRecommendations" + }, + { + "columns": [ + { + "value": "tmdbId", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "titles_tmdbId_type_unique", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + }, + { + "value": "releaseDate", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_type_releaseDate", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + }, + { + "value": "firstAirDate", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_type_firstAirDate", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "lastFetchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_lastFetchedAt", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + }, + { + "value": "lastFetchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "titles_type_status_lastFetchedAt", + "entityType": "indexes", + "table": "titles" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "watchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_userId_watchedAt", + "entityType": "indexes", + "table": "userEpisodeWatches" + }, + { + "columns": [ + { + "value": "episodeId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_episodeId", + "entityType": "indexes", + "table": "userEpisodeWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "episodeId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userEpisodeWatches_userId_episodeId", + "entityType": "indexes", + "table": "userEpisodeWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "watchedAt", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_userId_watchedAt", + "entityType": "indexes", + "table": "userMovieWatches" + }, + { + "columns": [ + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_titleId", + "entityType": "indexes", + "table": "userMovieWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userMovieWatches_userId_titleId", + "entityType": "indexes", + "table": "userMovieWatches" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "platformId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "userPlatforms_userId_platformId", + "entityType": "indexes", + "table": "userPlatforms" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userPlatforms_userId", + "entityType": "indexes", + "table": "userPlatforms" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "userRatings_userId_titleId", + "entityType": "indexes", + "table": "userRatings" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "titleId", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "userTitleStatus_userId_titleId", + "entityType": "indexes", + "table": "userTitleStatus" + }, + { + "columns": [ + { + "value": "userId", + "isExpression": false + }, + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "userTitleStatus_userId_status", + "entityType": "indexes", + "table": "userTitleStatus" + }, + { + "columns": [ + "token" + ], + "nameExplicit": false, + "name": "session_token_unique", + "entityType": "uniques", + "table": "session" + }, + { + "columns": [ + "email" + ], + "nameExplicit": false, + "name": "user_email_unique", + "entityType": "uniques", + "table": "user" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 6c7a479..c44d658 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -9,7 +9,7 @@ import { db } from "./client"; const log = createLogger("db"); export function runMigrations(migrationsFolder = path.join(import.meta.dir, "../drizzle")) { - log.info("Running database migrations..."); + log.debug("Running database migrations..."); migrate(db, { migrationsFolder }); - log.info("Database migrations complete"); + log.debug("Database migrations complete"); } diff --git a/packages/db/src/platforms.json b/packages/db/src/platforms.json new file mode 100644 index 0000000..bac57e2 --- /dev/null +++ b/packages/db/src/platforms.json @@ -0,0 +1,275 @@ +[ + { + "tmdbProviderIds": [8, 175, 1796], + "name": "Netflix", + "logoPath": "/pbpMk2JmcoNnQwx5JGpXngfoWtp.jpg", + "urlTemplate": "https://www.netflix.com/search?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [9, 119, 2100], + "name": "Amazon Prime Video", + "logoPath": "/qR6FKvnPBx2O37FDg8PNM7efwF3.jpg", + "urlTemplate": "https://www.amazon.com/s?i=instant-video&k={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [10], + "name": "Amazon Video", + "logoPath": "/seGSXajazLMCKGB5hnRCidtjay1.jpg", + "urlTemplate": "https://www.amazon.com/s?i=instant-video&k={title}", + "isSubscription": false + }, + { + "tmdbProviderIds": [337, 2739], + "name": "Disney+", + "logoPath": "/97yvRBw1GzX7fXprcF80er19ot.jpg", + "urlTemplate": "https://www.disneyplus.com/search/{title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [350], + "name": "Apple TV+", + "logoPath": "/SPnB1qiCkYfirS2it3hZORwGVn.jpg", + "urlTemplate": "https://tv.apple.com/search?term={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [2], + "name": "Apple TV Store", + "logoPath": "/mcbz1LgtErU9p4UdbZ0rG6RTWHX.jpg", + "urlTemplate": "https://tv.apple.com/search?term={title}", + "isSubscription": false + }, + { + "tmdbProviderIds": [384, 1825, 1899], + "name": "HBO Max", + "logoPath": "/jbe4gVSfRlbPTdESXhEKpornsfu.jpg", + "urlTemplate": "https://play.max.com/search?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [15], + "name": "Hulu", + "logoPath": "/bxBlRPEPpMVDc4jMhSrTf2339DW.jpg", + "urlTemplate": "https://www.hulu.com/search?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [531, 582, 633, 1770, 1853, 2303, 2616, 37], + "name": "Paramount+", + "logoPath": "/fts6X10Jn4QT0X6ac3udKEn2tJA.jpg", + "urlTemplate": "https://www.paramountplus.com/search/?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [386, 387], + "name": "Peacock", + "logoPath": "/2aGrp1xw3qhwCYvNGAJZPdjfeeX.jpg", + "urlTemplate": "https://www.peacocktv.com/search?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [3], + "name": "Google Play Movies", + "logoPath": "/8z7rC8uIDaTM91X0ZfkRf04ydj2.jpg", + "urlTemplate": "https://play.google.com/store/search?q={title}&c=movies", + "isSubscription": false + }, + { + "tmdbProviderIds": [7, 332], + "name": "Fandango at Home", + "logoPath": "/19fkcOz0xeUgCVW8tO85uOYnYK9.jpg", + "urlTemplate": "https://www.vudu.com/content/movies/search?searchString={title}", + "isSubscription": false + }, + { + "tmdbProviderIds": [68], + "name": "Microsoft Store", + "logoPath": "/shq88b09gTBYC4hA7K7MUL8Q4zP.jpg", + "urlTemplate": "https://www.microsoft.com/en-us/store/movies-and-tv", + "isSubscription": false + }, + { + "tmdbProviderIds": [188, 192], + "name": "YouTube", + "logoPath": "/pTnn5JwWr4p3pG8H6VrpiQo7Vs0.jpg", + "urlTemplate": "https://www.youtube.com/results?search_query={title}", + "isSubscription": false + }, + { + "tmdbProviderIds": [2528], + "name": "YouTube TV", + "logoPath": "/x9zOHTUkQzt3PgPVKbMH9CKBwLK.jpg", + "urlTemplate": "https://tv.youtube.com/", + "isSubscription": true + }, + { + "tmdbProviderIds": [283, 1968], + "name": "Crunchyroll", + "logoPath": "/fzN5Jok5Ig1eJ7gyNGoMhnLSCfh.jpg", + "urlTemplate": "https://www.crunchyroll.com/search?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [43, 634, 1794, 1855], + "name": "Starz", + "logoPath": "/yIKwylTLP1u8gl84Is7FItpYLGL.jpg", + "urlTemplate": "https://www.starz.com/search?query={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [526, 528, 635, 1854, 352], + "name": "AMC+", + "logoPath": "/ovmu6uot1XVvsemM2dDySXLiX57.jpg", + "urlTemplate": "https://www.amcplus.com/search?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [520, 584], + "name": "Discovery+", + "logoPath": "/eMTnWwNVtThkjvQA6zwxaoJG9NE.jpg", + "urlTemplate": "https://www.discoveryplus.com/search/{title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [11, 201], + "name": "MUBI", + "logoPath": "/x570VpH2C9EKDf1riP83rYc5dnL.jpg", + "urlTemplate": "https://mubi.com/search?query={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [99, 204], + "name": "Shudder", + "logoPath": "/vEtdiYRPRbDCp1Tcn3BEPF1Ni76.jpg", + "urlTemplate": "https://www.shudder.com/search?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [151, 1852, 197], + "name": "BritBox", + "logoPath": "/8oA7IcDNNUtBa9JYB5kQ8hrDz5o.jpg", + "urlTemplate": "https://www.britbox.com/search?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [87, 196], + "name": "Acorn TV", + "logoPath": "/doCc555FPPgGtuaZJxf9QZVpIp5.jpg", + "urlTemplate": "https://acorn.tv/search/?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [190], + "name": "Curiosity Stream", + "logoPath": "/oR1aNm1Qu9jQBkW4VrGPWhqbC3P.jpg", + "urlTemplate": "https://curiositystream.com/search?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [34, 583, 636], + "name": "MGM+", + "logoPath": "/ctiRpS16dlaTXQBSsiFncMrgWmh.jpg", + "urlTemplate": "https://www.mgmplus.com/search?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [143, 205], + "name": "Sundance Now", + "logoPath": "/1Edma9SrJnqkQW3BqFd2rJNHZvX.jpg", + "urlTemplate": "https://www.sundancenow.com/search?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [251, 343], + "name": "ALLBLK", + "logoPath": "/4cKdiYEPW1BsWLb9UmNzAyUlD5p.jpg", + "urlTemplate": "https://www.allblk.tv/search?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [73], + "name": "Tubi", + "logoPath": "/zLYr7OPvpskMA4S79E3vlCi71iC.jpg", + "urlTemplate": "https://tubitv.com/search/{title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [300], + "name": "Pluto TV", + "logoPath": "/dB8G41Q6tSL5NBisrIeqByfepBc.jpg", + "urlTemplate": "https://pluto.tv/search/details?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [257], + "name": "fuboTV", + "logoPath": "/9BgaNQRMDvVlji1JBZi6tcfxpKx.jpg", + "urlTemplate": "https://www.fubo.tv/search/{title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [2383], + "name": "Philo", + "logoPath": "/ptmbGSttkyzawLbxx9MElmxKuVo.jpg", + "urlTemplate": "https://www.philo.com/", + "isSubscription": true + }, + { + "tmdbProviderIds": [191], + "name": "Kanopy", + "logoPath": "/rcBwnERpNfPfWB5DaSTyEMCZbCA.jpg", + "urlTemplate": "https://www.kanopy.com/search?q={title}", + "isSubscription": false + }, + { + "tmdbProviderIds": [212], + "name": "Hoopla", + "logoPath": "/j7D006Uy3UWwZ6G0xH6BMgIWTzH.jpg", + "urlTemplate": "https://www.hoopladigital.com/search?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [207], + "name": "The Roku Channel", + "logoPath": "/wQzSN83BnWVgO7xEh0SeTVqtrFv.jpg", + "urlTemplate": "https://therokuchannel.roku.com/search/{title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [258], + "name": "Criterion Channel", + "logoPath": "/yhrtzYd43pFIhRq0ruO8umJPuyn.jpg", + "urlTemplate": "https://www.criterionchannel.com/search?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [83], + "name": "The CW", + "logoPath": "/spcwROYevucLluqZZ8Fv75UuTBt.jpg", + "urlTemplate": "https://www.cwtv.com/search/?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [209, 293, 294], + "name": "PBS", + "logoPath": "/iLjStQKQwzyxXJb3jyNpvDmW9mx.jpg", + "urlTemplate": "https://www.pbs.org/search/?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [538, 2077], + "name": "Plex", + "logoPath": "/vLZKlXUNDcZR7ilvfY9Wr9k80FZ.jpg", + "urlTemplate": "https://www.plex.tv/search/?q={title}", + "isSubscription": true + }, + { + "tmdbProviderIds": [457], + "name": "ViX", + "logoPath": "/jwRPknT20dfU1GeVqbcDXFyvtdG.jpg", + "urlTemplate": "https://vix.com/es-es/search?q={title}", + "isSubscription": true + } +] diff --git a/packages/db/src/queries/auth-cleanup.ts b/packages/db/src/queries/auth-cleanup.ts index 2678c50..349b2ee 100644 --- a/packages/db/src/queries/auth-cleanup.ts +++ b/packages/db/src/queries/auth-cleanup.ts @@ -4,25 +4,17 @@ import { db } from "../client"; import { session, verification } from "../schema"; export function deleteExpiredSessions(): number { - const now = new Date(); - const count = db - .select({ id: session.id }) - .from(session) - .where(lt(session.expiresAt, now)) + return db + .delete(session) + .where(lt(session.expiresAt, new Date())) + .returning({ id: session.id }) .all().length; - if (count === 0) return 0; - db.delete(session).where(lt(session.expiresAt, now)).run(); - return count; } export function deleteExpiredVerifications(): number { - const now = new Date(); - const count = db - .select({ id: verification.id }) - .from(verification) - .where(lt(verification.expiresAt, now)) + return db + .delete(verification) + .where(lt(verification.expiresAt, new Date())) + .returning({ id: verification.id }) .all().length; - if (count === 0) return 0; - db.delete(verification).where(lt(verification.expiresAt, now)).run(); - return count; } diff --git a/packages/db/src/queries/availability.ts b/packages/db/src/queries/availability.ts index c758a42..e71aa23 100644 --- a/packages/db/src/queries/availability.ts +++ b/packages/db/src/queries/availability.ts @@ -1,7 +1,7 @@ -import { and, eq, sql } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { db } from "../client"; -import { platforms, titleAvailability } from "../schema"; +import { platformTmdbIds, platforms, titleAvailability } from "../schema"; export function replaceAvailabilityTransaction( titleId: string, @@ -26,7 +26,6 @@ export function getAvailabilityForTitle(titleId: string) { providerName: platforms.name, logoPath: platforms.logoPath, urlTemplate: platforms.urlTemplate, - tmdbProviderId: platforms.tmdbProviderId, offerType: titleAvailability.offerType, }) .from(titleAvailability) @@ -36,7 +35,9 @@ export function getAvailabilityForTitle(titleId: string) { } /** - * Ensure a platform row exists for a TMDB provider. Upserts by tmdbProviderId. + * Ensure a platform row exists for a TMDB provider. + * Looks up via the platformTmdbIds junction table. + * Uses a transaction to prevent orphaned platform rows under concurrent refreshes. * Returns the platform ID. */ export function ensurePlatformForTmdbProvider( @@ -44,23 +45,38 @@ export function ensurePlatformForTmdbProvider( 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 db.transaction((tx) => { + const existing = tx + .select({ platformId: platformTmdbIds.platformId }) + .from(platformTmdbIds) + .where(eq(platformTmdbIds.tmdbProviderId, tmdbProviderId)) + .get(); - return row!.id; + if (existing) { + tx.update(platforms).set({ logoPath }).where(eq(platforms.id, existing.platformId)).run(); + return existing.platformId; + } + + const id = Bun.randomUUIDv7(); + tx.insert(platforms).values({ id, name, logoPath }).run(); + tx.insert(platformTmdbIds) + .values({ platformId: id, tmdbProviderId }) + .onConflictDoNothing() + .run(); + + // Verify our insert won (handles race with another writer) + const mapping = tx + .select({ platformId: platformTmdbIds.platformId }) + .from(platformTmdbIds) + .where(eq(platformTmdbIds.tmdbProviderId, tmdbProviderId)) + .get()!; + + if (mapping.platformId !== id) { + // Another transaction beat us — clean up our orphan + tx.delete(platforms).where(eq(platforms.id, id)).run(); + tx.update(platforms).set({ logoPath }).where(eq(platforms.id, mapping.platformId)).run(); + } + + return mapping.platformId; + }); } diff --git a/packages/db/src/queries/cache.ts b/packages/db/src/queries/cache.ts index 3b82083..7340253 100644 --- a/packages/db/src/queries/cache.ts +++ b/packages/db/src/queries/cache.ts @@ -34,9 +34,9 @@ function collectTitleImagePaths(titleIds: string[]): OrphanedImage[] { if (r.backdropPath) images.push({ category: "backdrops", path: r.backdropPath }); } - // Season posters + // Season posters + IDs const seasonRows = db - .select({ posterPath: seasons.posterPath }) + .select({ id: seasons.id, posterPath: seasons.posterPath }) .from(seasons) .where(inArray(seasons.titleId, batch)) .all(); @@ -45,12 +45,7 @@ function collectTitleImagePaths(titleIds: string[]): OrphanedImage[] { } // Episode stills (via seasons) - const seasonIds = db - .select({ id: seasons.id }) - .from(seasons) - .where(inArray(seasons.titleId, batch)) - .all() - .map((s) => s.id); + const seasonIds = seasonRows.map((s) => s.id); for (let j = 0; j < seasonIds.length; j += BATCH_SIZE) { const sBatch = seasonIds.slice(j, j + BATCH_SIZE); @@ -68,19 +63,44 @@ function collectTitleImagePaths(titleIds: string[]): OrphanedImage[] { return images; } -/** Collect profile paths for persons that will be orphaned (no remaining titleCast). */ -function collectOrphanedPersonImages(): OrphanedImage[] { - const orphaned = db - .select({ profilePath: persons.profilePath }) +/** Find person IDs that have no remaining titleCast entries. */ +function getOrphanedPersonIds(): string[] { + return db + .select({ id: persons.id }) .from(persons) .where( notInArray(persons.id, db.selectDistinct({ personId: titleCast.personId }).from(titleCast)), ) - .all(); + .all() + .map((p) => p.id); +} - return orphaned - .filter((p): p is { profilePath: string } => p.profilePath != null) - .map((p) => ({ category: "profiles" as const, path: p.profilePath })); +/** Collect profile paths for a set of orphaned person IDs. */ +function collectOrphanedPersonImages(orphanedIds: string[]): OrphanedImage[] { + if (orphanedIds.length === 0) return []; + const images: OrphanedImage[] = []; + for (let i = 0; i < orphanedIds.length; i += BATCH_SIZE) { + const batch = orphanedIds.slice(i, i + BATCH_SIZE); + const rows = db + .select({ profilePath: persons.profilePath }) + .from(persons) + .where(inArray(persons.id, batch)) + .all(); + for (const r of rows) { + if (r.profilePath) images.push({ category: "profiles", path: r.profilePath }); + } + } + return images; +} + +/** Delete orphaned persons by IDs, returning the count deleted. */ +function deleteOrphanedPersons(orphanedIds: string[]): number { + if (orphanedIds.length === 0) return 0; + for (let i = 0; i < orphanedIds.length; i += BATCH_SIZE) { + const batch = orphanedIds.slice(i, i + BATCH_SIZE); + db.delete(persons).where(inArray(persons.id, batch)).run(); + } + return orphanedIds.length; } export function purgeShellTitlesTransaction(): PurgeResult { @@ -92,8 +112,13 @@ export function purgeShellTitlesTransaction(): PurgeResult { .all(); if (shellTitles.length === 0) { - const orphanedImages = collectOrphanedPersonImages(); - return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons(), orphanedImages }; + const orphanedIds = getOrphanedPersonIds(); + const orphanedImages = collectOrphanedPersonImages(orphanedIds); + return { + deletedTitles: 0, + deletedPersons: deleteOrphanedPersons(orphanedIds), + orphanedImages, + }; } const libraryTitleIds = new Set( @@ -107,8 +132,13 @@ export function purgeShellTitlesTransaction(): PurgeResult { const toDelete = shellTitles.map((t) => t.id).filter((id) => !libraryTitleIds.has(id)); if (toDelete.length === 0) { - const orphanedImages = collectOrphanedPersonImages(); - return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons(), orphanedImages }; + const orphanedIds = getOrphanedPersonIds(); + const orphanedImages = collectOrphanedPersonImages(orphanedIds); + return { + deletedTitles: 0, + deletedPersons: deleteOrphanedPersons(orphanedIds), + orphanedImages, + }; } // Collect image paths BEFORE cascade-deleting the title rows @@ -119,32 +149,17 @@ export function purgeShellTitlesTransaction(): PurgeResult { db.delete(titles).where(inArray(titles.id, batch)).run(); } - // After title cascade deletes, collect orphaned person images then delete persons - orphanedImages.push(...collectOrphanedPersonImages()); + // After title cascade deletes, find orphaned persons once and use for both operations + const orphanedIds = getOrphanedPersonIds(); + orphanedImages.push(...collectOrphanedPersonImages(orphanedIds)); return { deletedTitles: toDelete.length, - deletedPersons: purgeOrphanedPersons(), + deletedPersons: deleteOrphanedPersons(orphanedIds), orphanedImages, }; }); } export function purgeOrphanedPersons(): number { - const orphanedPersons = db - .select({ id: persons.id }) - .from(persons) - .where( - notInArray(persons.id, db.selectDistinct({ personId: titleCast.personId }).from(titleCast)), - ) - .all(); - - if (orphanedPersons.length === 0) return 0; - - const ids = orphanedPersons.map((p) => p.id); - for (let i = 0; i < ids.length; i += BATCH_SIZE) { - const batch = ids.slice(i, i + BATCH_SIZE); - db.delete(persons).where(inArray(persons.id, batch)).run(); - } - - return ids.length; + return deleteOrphanedPersons(getOrphanedPersonIds()); } diff --git a/packages/db/src/queries/cron.ts b/packages/db/src/queries/cron.ts index 7c1863c..06f6574 100644 --- a/packages/db/src/queries/cron.ts +++ b/packages/db/src/queries/cron.ts @@ -53,7 +53,7 @@ export function getStaleTitles(titleIds: string[], staleDate: Date) { export function getStaleNonLibraryTitles(staleDate: Date, limit: number) { return db - .select() + .select({ id: titles.id }) .from(titles) .where(and(isNotNull(titles.lastFetchedAt), lt(titles.lastFetchedAt, staleDate))) .limit(limit) @@ -94,7 +94,7 @@ export function getTitlesWithStaleOffersFetchedBefore(titleIds: string[], staleD export function getReturningTvShows() { const returningStatuses = ["Returning Series", "In Production"]; return db - .select() + .select({ id: titles.id, tmdbId: titles.tmdbId }) .from(titles) .where( and( @@ -128,19 +128,11 @@ export function getCastEntryForTitle(titleId: string) { } export function deleteOldCronRuns(beforeDate: Date): number { - const old = db - .select({ id: cronRuns.id }) - .from(cronRuns) + return db + .delete(cronRuns) .where(lt(cronRuns.startedAt, beforeDate)) - .all(); - if (old.length === 0) return 0; - const ids = old.map((r) => r.id); - for (let i = 0; i < ids.length; i += 500) { - db.delete(cronRuns) - .where(inArray(cronRuns.id, ids.slice(i, i + 500))) - .run(); - } - return old.length; + .returning({ id: cronRuns.id }) + .all().length; } export function getTitlesWithFreshRecommendations( diff --git a/packages/db/src/queries/discovery.ts b/packages/db/src/queries/discovery.ts index a4bd799..2015872 100644 --- a/packages/db/src/queries/discovery.ts +++ b/packages/db/src/queries/discovery.ts @@ -351,6 +351,7 @@ export function getUpcomingMovies( export function getAvailabilityByTitleIds(titleIds: string[]) { if (titleIds.length === 0) return []; + // Order by offerType so flatrate is picked first when the caller deduplicates return db .select({ titleId: titleAvailability.titleId, @@ -363,8 +364,11 @@ export function getAvailabilityByTitleIds(titleIds: string[]) { .where( and( inArray(titleAvailability.titleId, titleIds), - eq(titleAvailability.offerType, "flatrate"), + inArray(titleAvailability.offerType, ["flatrate", "free", "ads"]), ), ) + .orderBy( + sql`CASE ${titleAvailability.offerType} WHEN 'flatrate' THEN 0 WHEN 'free' THEN 1 ELSE 2 END`, + ) .all(); } diff --git a/packages/db/src/queries/image-cache.ts b/packages/db/src/queries/image-cache.ts index da91f0c..eb6fdfd 100644 --- a/packages/db/src/queries/image-cache.ts +++ b/packages/db/src/queries/image-cache.ts @@ -1,4 +1,4 @@ -import { eq, inArray } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { db } from "../client"; import { @@ -39,19 +39,11 @@ export function getSeasonPostersForTitle(titleId: string) { // ─── Episode stills ────────────────────────────────────────────────── export function getEpisodeStillsForTitle(titleId: string) { - const allSeasons = db - .select({ id: seasons.id }) - .from(seasons) - .where(eq(seasons.titleId, titleId)) - .all(); - - const seasonIds = allSeasons.map((s) => s.id); - if (seasonIds.length === 0) return []; - return db .select({ stillPath: episodes.stillPath }) .from(episodes) - .where(inArray(episodes.seasonId, seasonIds)) + .innerJoin(seasons, eq(episodes.seasonId, seasons.id)) + .where(eq(seasons.titleId, titleId)) .all(); } diff --git a/packages/db/src/queries/imports.ts b/packages/db/src/queries/imports.ts index 18501f1..05c6f82 100644 --- a/packages/db/src/queries/imports.ts +++ b/packages/db/src/queries/imports.ts @@ -89,22 +89,14 @@ export function getActiveImportJobForUser(userId: string) { /** Mark any running/pending import jobs as errored — called on server startup to recover from crashes. */ export function recoverStaleImportJobs(): number { - const stale = db - .select({ id: importJobs.id }) - .from(importJobs) + return db + .update(importJobs) + .set({ + status: "error", + finishedAt: new Date(), + errors: JSON.stringify(["Import interrupted by server restart"]), + }) .where(inArray(importJobs.status, ["pending", "running"])) - .all(); - if (stale.length === 0) return 0; - const now = new Date(); - for (const { id } of stale) { - db.update(importJobs) - .set({ - status: "error", - finishedAt: now, - errors: JSON.stringify(["Import interrupted by server restart"]), - }) - .where(eq(importJobs.id, id)) - .run(); - } - return stale.length; + .returning({ id: importJobs.id }) + .all().length; } diff --git a/packages/db/src/queries/integrations.ts b/packages/db/src/queries/integrations.ts index 49f1fcb..ed1d308 100644 --- a/packages/db/src/queries/integrations.ts +++ b/packages/db/src/queries/integrations.ts @@ -17,6 +17,18 @@ export function getRecentEventsForIntegration(integrationId: string, limit = 10) .all(); } +export function getRecentEventsForIntegrations(integrationIds: string[], limit = 10) { + if (integrationIds.length === 0) + return new Map>(); + + const result = new Map>(); + for (const integrationId of integrationIds) { + const events = getRecentEventsForIntegration(integrationId, limit); + if (events.length > 0) result.set(integrationId, events); + } + return result; +} + export function getIntegrationByUserAndProvider(userId: string, provider: string) { return db .select() diff --git a/packages/db/src/queries/library.ts b/packages/db/src/queries/library.ts index ce3600b..54c9ba6 100644 --- a/packages/db/src/queries/library.ts +++ b/packages/db/src/queries/library.ts @@ -224,6 +224,10 @@ export function getFilteredLibrary(userId: string, filters: LibraryFilters) { // Build query with display status when needed for filtering if (needsDisplayStatus) { + // Filter by display status in SQL so pagination works at the DB level + const statusValues = filters.statuses!.map((s) => sql`${s}`); + conditions.push(sql`(${displayStatusExpr()}) IN (${sql.join(statusValues, sql`, `)})`); + const rows = db .select({ titleId: titles.id, @@ -239,6 +243,7 @@ export function getFilteredLibrary(userId: string, filters: LibraryFilters) { userStatus: userTitleStatus.status, userRating: userRatings.ratingStars, displayStatus: displayStatusExpr().as("display_status"), + totalCount: sql`count(*) over()`.as("totalCount"), }) .from(titles) .innerJoin( @@ -251,18 +256,15 @@ export function getFilteredLibrary(userId: string, filters: LibraryFilters) { ) .where(and(...conditions)) .orderBy(...sortExpressions) + .limit(filters.limit) + .offset(offset) .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); + const totalResults = rows[0]?.totalCount ?? 0; + const items = rows.map(({ totalCount: _, ...item }) => item); return { - items: paged.map((row) => { - const { displayStatus, ...item } = row; - return Object.assign(item, { displayStatus }); - }), + items, page: filters.page, totalPages: Math.max(1, Math.ceil(totalResults / filters.limit)), totalResults, diff --git a/packages/db/src/queries/metadata.ts b/packages/db/src/queries/metadata.ts index a103fae..695d1ab 100644 --- a/packages/db/src/queries/metadata.ts +++ b/packages/db/src/queries/metadata.ts @@ -267,7 +267,6 @@ export function getAvailabilityOffersForTitle(titleId: string) { providerName: platforms.name, logoPath: platforms.logoPath, urlTemplate: platforms.urlTemplate, - tmdbProviderId: platforms.tmdbProviderId, offerType: titleAvailability.offerType, }) .from(titleAvailability) diff --git a/packages/db/src/queries/system-health.ts b/packages/db/src/queries/system-health.ts index 4244791..93abe4e 100644 --- a/packages/db/src/queries/system-health.ts +++ b/packages/db/src/queries/system-health.ts @@ -1,4 +1,4 @@ -import { count, desc, eq } from "drizzle-orm"; +import { count, desc, eq, inArray, sql } from "drizzle-orm"; import { db } from "../client"; import { cronRuns, episodes, titles, user } from "../schema"; @@ -23,3 +23,26 @@ export function getLatestCronRun(jobName: string) { .limit(1) .get(); } + +export function getLatestCronRuns(jobNames: string[]) { + if (jobNames.length === 0) return new Map(); + const rows = db + .select({ + id: cronRuns.id, + jobName: cronRuns.jobName, + status: cronRuns.status, + startedAt: cronRuns.startedAt, + finishedAt: cronRuns.finishedAt, + durationMs: cronRuns.durationMs, + errorMessage: cronRuns.errorMessage, + rn: sql`row_number() over (partition by ${cronRuns.jobName} order by ${cronRuns.startedAt} desc)`.as( + "rn", + ), + }) + .from(cronRuns) + .where(inArray(cronRuns.jobName, jobNames)) + .all() + .filter((r) => r.rn === 1); + + return new Map(rows.map((r) => [r.jobName, r])); +} diff --git a/packages/db/src/queries/tracking.ts b/packages/db/src/queries/tracking.ts index 2e321df..bf92102 100644 --- a/packages/db/src/queries/tracking.ts +++ b/packages/db/src/queries/tracking.ts @@ -69,6 +69,17 @@ export function getEpisodeTitleId(episodeId: string): string | null { return row?.titleId ?? null; } +export function getEpisodeTitleIds(episodeIds: string[]): Map { + if (episodeIds.length === 0) return new Map(); + const rows = db + .select({ episodeId: episodes.id, titleId: seasons.titleId }) + .from(episodes) + .innerJoin(seasons, eq(episodes.seasonId, seasons.id)) + .where(inArray(episodes.id, episodeIds)) + .all(); + return new Map(rows.map((r) => [r.episodeId, r.titleId])); +} + export function batchInsertEpisodeWatchesTransaction( userId: string, episodeIds: string[], @@ -168,7 +179,7 @@ export function batchInsertMissingEpisodeWatches( export function countDistinctEpisodeWatches(userId: string, episodeIds: string[]): number { if (episodeIds.length === 0) return 0; - const [result] = db + const result = db .select({ count: sql`count(distinct ${userEpisodeWatches.episodeId})`, }) @@ -176,8 +187,8 @@ export function countDistinctEpisodeWatches(userId: string, episodeIds: string[] .where( and(eq(userEpisodeWatches.userId, userId), inArray(userEpisodeWatches.episodeId, episodeIds)), ) - .all(); - return result.count; + .get(); + return result?.count ?? 0; } export function upsertRating( @@ -277,6 +288,15 @@ export function getSeasonEpisodes(seasonId: string) { return db.select().from(episodes).where(eq(episodes.seasonId, seasonId)).all(); } +export function getSeasonEpisodeIds(seasonId: string): string[] { + return db + .select({ id: episodes.id }) + .from(episodes) + .where(eq(episodes.seasonId, seasonId)) + .all() + .map((e) => e.id); +} + export function getSeasonById(seasonId: string) { return db.select().from(seasons).where(eq(seasons.id, seasonId)).get(); } diff --git a/packages/db/src/queries/user-platforms.ts b/packages/db/src/queries/user-platforms.ts index 37ecb39..62f5232 100644 --- a/packages/db/src/queries/user-platforms.ts +++ b/packages/db/src/queries/user-platforms.ts @@ -1,7 +1,7 @@ -import { eq, inArray } from "drizzle-orm"; +import { asc, desc, eq, inArray } from "drizzle-orm"; import { db } from "../client"; -import { platforms, userPlatforms } from "../schema"; +import { platformTmdbIds, platforms, userPlatforms } from "../schema"; export function getUserPlatformIds(userId: string): string[] { return db @@ -17,15 +17,14 @@ export function getUserPlatforms(userId: string) { .select({ id: platforms.id, name: platforms.name, - tmdbProviderId: platforms.tmdbProviderId, logoPath: platforms.logoPath, urlTemplate: platforms.urlTemplate, - displayOrder: platforms.displayOrder, + isSubscription: platforms.isSubscription, }) .from(userPlatforms) .innerJoin(platforms, eq(userPlatforms.platformId, platforms.id)) .where(eq(userPlatforms.userId, userId)) - .orderBy(platforms.displayOrder) + .orderBy(desc(platforms.isSubscription), asc(platforms.name)) .all(); } @@ -42,7 +41,11 @@ export function setUserPlatforms(userId: string, platformIds: string[]): void { } export function getAllPlatforms() { - return db.select().from(platforms).orderBy(platforms.displayOrder).all(); + return db + .select() + .from(platforms) + .orderBy(desc(platforms.isSubscription), asc(platforms.name)) + .all(); } export function hasUserPlatforms(userId: string): boolean { @@ -66,3 +69,32 @@ export function platformIdsExist(platformIds: string[]): boolean { .all(); return found.length === unique.length; } + +export function getTmdbProviderIdsForPlatform(platformId: string): number[] { + return db + .select({ tmdbProviderId: platformTmdbIds.tmdbProviderId }) + .from(platformTmdbIds) + .where(eq(platformTmdbIds.platformId, platformId)) + .all() + .map((r) => r.tmdbProviderId); +} + +export function getTmdbProviderIdsByPlatformIds(platformIds: string[]): Map { + if (platformIds.length === 0) return new Map(); + const rows = db + .select({ + platformId: platformTmdbIds.platformId, + tmdbProviderId: platformTmdbIds.tmdbProviderId, + }) + .from(platformTmdbIds) + .where(inArray(platformTmdbIds.platformId, platformIds)) + .all(); + + const map = new Map(); + for (const row of rows) { + const arr = map.get(row.platformId) ?? []; + arr.push(row.tmdbProviderId); + map.set(row.platformId, arr); + } + return map; +} diff --git a/packages/db/src/queries/webhooks.ts b/packages/db/src/queries/webhooks.ts index 3c1dbca..f55f3ce 100644 --- a/packages/db/src/queries/webhooks.ts +++ b/packages/db/src/queries/webhooks.ts @@ -1,4 +1,4 @@ -import { and, eq, gte, inArray, lt } from "drizzle-orm"; +import { and, eq, gte, lt } from "drizzle-orm"; import { db } from "../client"; import { integrationEvents, integrations, userEpisodeWatches, userMovieWatches } from "../schema"; @@ -31,29 +31,23 @@ export function getRecentEpisodeWatch(userId: string, episodeId: string, since: .get(); } -export function insertIntegrationEvent(values: typeof integrationEvents.$inferInsert): void { - db.insert(integrationEvents).values(values).run(); -} - -export function updateIntegrationLastEvent(connectionId: string): void { - db.update(integrations) - .set({ lastEventAt: new Date() }) - .where(eq(integrations.id, connectionId)) - .run(); +export function insertIntegrationEventTransaction( + values: typeof integrationEvents.$inferInsert, + connectionId: string, +): void { + db.transaction((tx) => { + tx.insert(integrationEvents).values(values).run(); + tx.update(integrations) + .set({ lastEventAt: new Date() }) + .where(eq(integrations.id, connectionId)) + .run(); + }); } export function deleteOldIntegrationEvents(beforeDate: Date): number { - const old = db - .select({ id: integrationEvents.id }) - .from(integrationEvents) + return db + .delete(integrationEvents) .where(lt(integrationEvents.receivedAt, beforeDate)) - .all(); - if (old.length === 0) return 0; - const ids = old.map((r) => r.id); - for (let i = 0; i < ids.length; i += 500) { - db.delete(integrationEvents) - .where(inArray(integrationEvents.id, ids.slice(i, i + 500))) - .run(); - } - return old.length; + .returning({ id: integrationEvents.id }) + .all().length; } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index e9050fa..0d27a9b 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -251,19 +251,25 @@ export const userRatings = sqliteTable( // ─── Platforms & Availability ──────────────────────────────────────── -export const platforms = sqliteTable( - "platforms", +export const platforms = sqliteTable("platforms", { + id: uuidPk(), + name: text("name").notNull(), + logoPath: text("logoPath"), + urlTemplate: text("urlTemplate"), + isSubscription: int("isSubscription", { mode: "boolean" }).notNull().default(true), +}); + +export const platformTmdbIds = sqliteTable( + "platformTmdbIds", { - id: uuidPk(), - name: text("name").notNull(), - tmdbProviderId: int("tmdbProviderId"), - logoPath: text("logoPath"), - urlTemplate: text("urlTemplate"), - displayOrder: int("displayOrder").notNull().default(0), + platformId: text("platformId") + .notNull() + .references(() => platforms.id, { onDelete: "cascade" }), + tmdbProviderId: int("tmdbProviderId").notNull(), }, (table) => [ - uniqueIndex("platforms_tmdbProviderId_unique").on(table.tmdbProviderId), - index("platforms_displayOrder").on(table.displayOrder), + uniqueIndex("platformTmdbIds_tmdbProviderId_unique").on(table.tmdbProviderId), + index("platformTmdbIds_platformId").on(table.platformId), ], ); diff --git a/packages/db/src/seed-platforms.ts b/packages/db/src/seed-platforms.ts index 6d9cdb6..68e80ec 100644 --- a/packages/db/src/seed-platforms.ts +++ b/packages/db/src/seed-platforms.ts @@ -1,198 +1,126 @@ -import { sql } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; + +import { createLogger } from "@sofa/logger"; import { db } from "./client"; -import { platforms } from "./schema"; +import platformData from "./platforms.json"; +import { platformTmdbIds, platforms, titleAvailability, userPlatforms } from "./schema"; -interface SeedPlatform { - tmdbProviderId: number; +const log = createLogger("seed-platforms"); + +export interface SeedPlatform { + tmdbProviderIds: number[]; name: string; + logoPath: string | null; urlTemplate: string; - displayOrder: number; + isSubscription: boolean; } -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 const SEED_DATA: SeedPlatform[] = platformData; 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(); + log.debug(`Seeding ${SEED_DATA.length} platforms`); 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, - }); + const existingPlatforms = new Map( + db + .select() + .from(platforms) + .all() + .map((p) => [p.id, p]), + ); + const existingMappings = db.select().from(platformTmdbIds).all(); + const tmdbToPlatform = new Map(existingMappings.map((m) => [m.tmdbProviderId, m.platformId])); + + for (const seed of SEED_DATA) { + // Find all existing platform IDs that any of this seed's TMDB IDs map to + const existingPlatformIds = [ + ...new Set( + seed.tmdbProviderIds + .map((id) => tmdbToPlatform.get(id)) + .filter((id): id is string => id != null), + ), + ]; + + let canonicalId: string; + + if (existingPlatformIds.length === 0) { + // Brand new platform + canonicalId = Bun.randomUUIDv7(); + db.insert(platforms) + .values({ + id: canonicalId, + name: seed.name, + logoPath: seed.logoPath, + urlTemplate: seed.urlTemplate, + isSubscription: seed.isSubscription, + }) + .run(); + log.debug(`Created platform "${seed.name}"`); + } else { + // Use the first existing platform as canonical + canonicalId = existingPlatformIds[0]!; + + // Only update if metadata actually changed + const existing = existingPlatforms.get(canonicalId); + if ( + !existing || + existing.name !== seed.name || + existing.logoPath !== seed.logoPath || + existing.urlTemplate !== seed.urlTemplate || + existing.isSubscription !== seed.isSubscription + ) { + db.update(platforms) + .set({ + name: seed.name, + logoPath: seed.logoPath, + urlTemplate: seed.urlTemplate, + isSubscription: seed.isSubscription, + }) + .where(eq(platforms.id, canonicalId)) + .run(); + log.debug(`Updated platform "${seed.name}"`); + } + + // Merge duplicates into the canonical platform + for (const oldId of existingPlatformIds.slice(1)) { + // Re-point titleAvailability rows, ignoring conflicts from duplicates + db.run( + sql`UPDATE OR IGNORE ${titleAvailability} SET "platformId" = ${canonicalId} WHERE "platformId" = ${oldId}`, + ); + // Delete any titleAvailability rows that couldn't be moved due to unique constraint + db.delete(titleAvailability).where(eq(titleAvailability.platformId, oldId)).run(); + + // Re-point userPlatforms rows, ignoring conflicts + db.run( + sql`UPDATE OR IGNORE ${userPlatforms} SET "platformId" = ${canonicalId} WHERE "platformId" = ${oldId}`, + ); + db.delete(userPlatforms).where(eq(userPlatforms.platformId, oldId)).run(); + + // Remove old TMDB ID mappings (cascade won't fire since we delete platform next) + db.delete(platformTmdbIds).where(eq(platformTmdbIds.platformId, oldId)).run(); + + // Delete the duplicate platform + db.delete(platforms).where(eq(platforms.id, oldId)).run(); + log.debug(`Merged duplicate platform ${oldId} into "${seed.name}" (${canonicalId})`); + } + } + + // Batch insert missing TMDB ID mappings + const missingTmdbIds = seed.tmdbProviderIds.filter( + (id) => tmdbToPlatform.get(id) !== canonicalId, + ); + if (missingTmdbIds.length > 0) { + db.insert(platformTmdbIds) + .values( + missingTmdbIds.map((tmdbProviderId) => ({ platformId: canonicalId, tmdbProviderId })), + ) + .onConflictDoNothing() + .run(); + log.debug(`Added ${missingTmdbIds.length} TMDB mapping(s) for "${seed.name}"`); + } } }); + + log.debug(`Seeded ${SEED_DATA.length} platforms`); } diff --git a/packages/i18n/src/po/en.po b/packages/i18n/src/po/en.po index d7b4f43..e272a39 100644 --- a/packages/i18n/src/po/en.po +++ b/packages/i18n/src/po/en.po @@ -275,6 +275,10 @@ msgstr "4★+" msgid "5★" msgstr "5★" +#: apps/web/src/components/library/library-filters.tsx +msgid "70s and earlier" +msgstr "70s and earlier" + #: apps/native/src/components/library/filter-sheet.tsx msgid "80s" msgstr "80s" @@ -284,7 +288,7 @@ msgid "90s" msgstr "90s" #: apps/native/src/app/(tabs)/(settings)/index.tsx -#: apps/web/src/components/settings/account-section.tsx +#: apps/web/src/routes/_app/settings.tsx msgid "Account" msgstr "" @@ -451,8 +455,8 @@ msgid "Any year" msgstr "Any year" #: apps/web/src/routes/_app/settings.tsx -msgid "App Settings" -msgstr "" +#~ msgid "App Settings" +#~ msgstr "" #: apps/native/src/app/(tabs)/(settings)/index.tsx msgid "Application" @@ -540,8 +544,13 @@ msgid "backups." msgstr "backups." #: apps/web/src/components/titles/title-availability.tsx -msgid "Buy" -msgstr "" +#~ msgid "Buy" +#~ msgstr "" + +#: apps/native/src/app/title/[id].tsx +#: apps/web/src/components/titles/title-availability.tsx +msgid "Buy or Rent" +msgstr "Buy or Rent" #: apps/web/src/components/settings/danger-section.tsx msgid "Cache management" @@ -661,8 +670,12 @@ msgstr "Choose how to import your {sourceLabel} data." #~ msgstr "Choose your preferred display language" #: apps/web/src/components/settings/streaming-services-section.tsx -msgid "Choose your subscriptions" -msgstr "Choose your subscriptions" +msgid "Choose your preferred streaming platforms" +msgstr "Choose your preferred streaming platforms" + +#: apps/web/src/components/settings/streaming-services-section.tsx +#~ msgid "Choose your subscriptions" +#~ msgstr "Choose your subscriptions" #: apps/native/src/app/(tabs)/(library)/index.tsx #: apps/native/src/app/(tabs)/(library)/index.tsx @@ -1427,8 +1440,8 @@ msgid "Finished importing your Sofa export." msgstr "Finished importing your Sofa export." #: apps/web/src/components/titles/title-availability.tsx -msgid "Free" -msgstr "" +#~ msgid "Free" +#~ msgstr "" #: apps/web/src/components/settings/danger-section.tsx msgid "Free up disk space by clearing cached metadata and images" @@ -1573,7 +1586,7 @@ msgid "Image cache and backup disk usage" msgstr "" #: apps/web/src/components/settings/account-section.tsx -#: apps/web/src/components/settings/imports-section.tsx +#: apps/web/src/routes/_app/settings.tsx msgid "Import" msgstr "" @@ -1673,7 +1686,7 @@ msgid "Install the Webhook plugin from Jellyfin's plugin catalog" msgstr "" #: apps/native/src/components/settings/integrations-section.tsx -#: apps/web/src/components/settings/integrations-section.tsx +#: apps/web/src/routes/_app/settings.tsx msgid "Integrations" msgstr "" @@ -2346,7 +2359,6 @@ msgstr "Pre-1970" #: apps/native/src/app/(tabs)/(library)/index.tsx #: apps/native/src/app/(tabs)/(library)/index.tsx #: apps/web/src/components/library/library-filters.tsx -#: apps/web/src/components/library/library-filters.tsx msgid "Pre-1980" msgstr "Pre-1980" @@ -2653,8 +2665,8 @@ msgid "Removed from library" msgstr "" #: apps/web/src/components/titles/title-availability.tsx -msgid "Rent" -msgstr "" +#~ msgid "Rent" +#~ msgstr "" #: apps/web/src/routes/setup.tsx msgid "Request an API key" @@ -3114,6 +3126,7 @@ msgstr "" msgid "Storage" msgstr "" +#: apps/native/src/app/title/[id].tsx #: apps/web/src/components/titles/title-availability.tsx msgid "Stream" msgstr "" @@ -3123,8 +3136,16 @@ msgid "Streaming" msgstr "Streaming" #: apps/web/src/components/settings/streaming-services-section.tsx -msgid "Streaming Services" -msgstr "Streaming Services" +msgid "Streaming data provided by <0/><1>JustWatch" +msgstr "Streaming data provided by <0/><1>JustWatch" + +#: apps/web/src/components/settings/streaming-services-section.tsx +#~ msgid "Streaming Services" +#~ msgstr "Streaming Services" + +#: apps/web/src/components/settings/streaming-services-section.tsx +msgid "Subscriptions" +msgstr "Subscriptions" #: apps/web/src/components/settings/backup-schedule-section.tsx msgid "Sunday" @@ -3567,8 +3588,8 @@ msgstr "" #: apps/web/src/components/titles/title-availability.tsx #: apps/web/src/components/titles/title-availability.tsx -msgid "Watch on {name}" -msgstr "" +#~ msgid "Watch on {name}" +#~ msgstr "" #: apps/native/src/components/titles/season-accordion.tsx #: apps/native/src/lib/title-actions.ts @@ -3651,8 +3672,8 @@ msgid "Where to Watch" msgstr "" #: apps/web/src/components/titles/title-availability.tsx -msgid "With Ads" -msgstr "" +#~ msgid "With Ads" +#~ msgstr "" #: apps/native/src/app/person/[id].tsx #: apps/native/src/components/search/recently-viewed-row-content.tsx diff --git a/packages/test/src/db.ts b/packages/test/src/db.ts index 804d2f7..7cf37d8 100644 --- a/packages/test/src/db.ts +++ b/packages/test/src/db.ts @@ -16,6 +16,7 @@ const { userEpisodeWatches, userTitleStatus, userRatings, + platformTmdbIds, platforms, titleAvailability, userPlatforms, @@ -178,9 +179,9 @@ export function insertPlatform( id?: string; name?: string; tmdbProviderId?: number; + tmdbProviderIds?: number[]; logoPath?: string; urlTemplate?: string; - displayOrder?: number; } = {}, ) { const id = overrides.id ?? "platform-1"; @@ -189,12 +190,15 @@ export function insertPlatform( .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(); + + const tmdbIds = overrides.tmdbProviderIds ?? [overrides.tmdbProviderId ?? 8]; + for (const tmdbId of tmdbIds) { + testDb.insert(platformTmdbIds).values({ platformId: id, tmdbProviderId: tmdbId }).run(); + } return id; } diff --git a/scripts/sync-tmdb-providers.ts b/scripts/sync-tmdb-providers.ts new file mode 100644 index 0000000..e12c566 --- /dev/null +++ b/scripts/sync-tmdb-providers.ts @@ -0,0 +1,95 @@ +/** + * Pulls all watch providers from the TMDB API, updates logo paths for existing + * providers, and auto-appends any missing ones to platforms.json. + * + * Usage: bun scripts/sync-tmdb-providers.ts [--region US] + */ + +import type { SeedPlatform } from "../packages/db/src/seed-platforms"; +import { getWatchProviderList } from "../packages/tmdb/src/client"; + +const region = process.argv.includes("--region") + ? (process.argv[process.argv.indexOf("--region") + 1] ?? "US") + : "US"; + +console.log(`Fetching TMDB watch providers for region: ${region}\n`); + +const [movieProviders, tvProviders] = await Promise.all([ + getWatchProviderList("movie", region), + getWatchProviderList("tv", region), +]); + +// Union by provider_id, preferring movie entry for metadata +const allProviders = new Map< + number, + { + provider_id: number; + provider_name: string; + logo_path: string | null; + display_priorities: Record; + } +>(); +for (const p of [...movieProviders, ...tvProviders]) { + if (!allProviders.has(p.provider_id)) { + allProviders.set(p.provider_id, p); + } +} + +// Read current platforms JSON +const jsonPath = new URL("../packages/db/src/platforms.json", import.meta.url).pathname; +const seedData: SeedPlatform[] = await Bun.file(jsonPath).json(); + +// Build set of known TMDB IDs +const knownIds = new Set(seedData.flatMap((p) => p.tmdbProviderIds)); + +// Update logo paths for existing entries +let logosUpdated = 0; +for (const entry of seedData) { + const firstId = entry.tmdbProviderIds[0]; + if (firstId == null) continue; + const tmdbProvider = allProviders.get(firstId); + if (!tmdbProvider?.logo_path) continue; + if (entry.logoPath !== tmdbProvider.logo_path) { + entry.logoPath = tmdbProvider.logo_path; + logosUpdated++; + } +} +console.log(`Updated ${logosUpdated} logo paths for existing entries\n`); + +// Find missing providers +const missing = [...allProviders.values()] + .filter((p) => !knownIds.has(p.provider_id)) + .sort((a, b) => { + const aPriority = a.display_priorities?.[region] ?? 999; + const bPriority = b.display_priorities?.[region] ?? 999; + return aPriority - bPriority; + }); + +console.log(`Total TMDB providers for ${region}: ${allProviders.size}`); +console.log(`Already in seed data: ${knownIds.size}`); +console.log(`Missing: ${missing.length}\n`); + +if (missing.length > 0) { + for (const p of missing) { + seedData.push({ + tmdbProviderIds: [p.provider_id], + name: p.provider_name, + logoPath: p.logo_path, + urlTemplate: "", + isSubscription: true, + }); + } + + console.log(`Added ${missing.length} new providers`); + console.log("\nTop 20 added:"); + for (const p of missing.slice(0, 20)) { + const priority = p.display_priorities?.[region] ?? "?"; + console.log(` [${p.provider_id}] ${p.provider_name} (priority: ${priority})`); + } + if (missing.length > 20) { + console.log(` ... and ${missing.length - 20} more`); + } +} + +await Bun.write(jsonPath, JSON.stringify(seedData, null, 2) + "\n"); +console.log(`\nWrote ${seedData.length} providers to platforms.json`);