Replace tmdb-* URL routing with resolveTitle/resolvePerson server actions

- Add `resolveTitle` and `resolvePerson` server actions that import
  from TMDB and return the internal DB id
- Remove `tmdb-{id}-{type}` URL pattern and server-side redirect logic
  from TitleDetailPage and PersonDetailPage
- Rename `importTitle` → `getOrFetchTitleByTmdbId`; add `getOrFetchTitle`
  combining fetch + children lookup
- Update HeroBanner, TitleCard, and CommandPalette to call resolve
  actions client-side before pushing to router
- Batch availability offer inserts into a single transaction
This commit is contained in:
2026-03-07 14:27:03 -05:00
parent ccd482db4e
commit 8a45aa1c36
17 changed files with 552 additions and 419 deletions
+122 -21
View File
@@ -1,36 +1,137 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # 🛋️  Sofa
## Getting Started Sofa is a self-hosted movie and TV tracker for nerds. Track what you've watched, discover what's next, and plug your data into your existing home media stack.
First, run the development server: ## What it does
- Track episode-level progress for TV series and pick shows back up from a dedicated "Continue Watching" view
- Mark movies as watched to discover more like them
- Rate titles, browse cast and crew, and get recommendations based on what you are already tracking
- Search TMDB and explore trending movies and shows without leaving your own instance
- Show streaming availability from TMDB's US provider data
- Automatically log completed watches from Plex, Jellyfin, or Emby webhooks
- Expose your watchlist as import lists for Sonarr and Radarr
- Runs on SQLite with local image caching, built-in backups, and no external database requirement
- Supports local accounts or OIDC SSO for private instances
> [!NOTE]
> Sofa is extremely US-centric right now, in terms of streaming providers, content rating systems, etc. Contributions to address this are more than welcome!
## Quick start
A minimal [`docker-compose.yml`](./docker-compose.yml) is provided in this repo. For most setups, the shortest path is:
1. Copy the example environment file:
```bash ```bash
npm run dev cp .env.example .env
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 2. Fill in the required values:
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ```env
TMDB_API_READ_ACCESS_TOKEN=your_tmdb_read_access_token
BETTER_AUTH_SECRET=generate_a_long_random_secret
BETTER_AUTH_URL=http://localhost:3000
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 3. Start the container:
## Learn More ```bash
docker compose up -d
```
To learn more about Next.js, take a look at the following resources: 4. Open `http://localhost:3000` and create the first account. The first account becomes the admin automatically, and registration closes after that by default.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. > The included Compose file uses `ghcr.io/jakejarvis/sofa:edge`. If you prefer to pin releases, switch to a published version tag like `ghcr.io/jakejarvis/sofa:<version>` or use the matching `jakejarvis/sofa:<version>` image on Docker Hub. Multi-arch images are published for `linux/amd64` and `linux/arm64`.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! ## Required setup
## Deploy on Vercel ### TMDB
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Sofa uses [TMDB](https://www.themoviedb.org/) for metadata, posters, cast, recommendations, and streaming availability. You need a TMDB API Read Access Token before the app can do anything useful.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. Create one here:
- [TMDB signup](https://www.themoviedb.org/signup)
- [API settings](https://www.themoviedb.org/settings/api)
> [!TIP]
> We want the **API Read Access Token**, not the shorter API key.
### Auth secret
`BETTER_AUTH_SECRET` should be a long random string. To generate one:
```bash
npx @better-auth/cli@latest secret
# or
openssl rand -base64 32
```
### Public URL
Set `BETTER_AUTH_URL` to the real external URL of your instance. This especially matters for login flows and OIDC callbacks behind a reverse proxy (like nginx or Traefik).
## Configuration
| Variable | Required | Notes |
| --- | --- | --- |
| `TMDB_API_READ_ACCESS_TOKEN` | Yes | TMDB metadata and discovery |
| `BETTER_AUTH_SECRET` | Yes | Session and auth secret |
| `BETTER_AUTH_URL` | Yes | Public base URL of the app |
| `DATA_DIR` | No | Root data directory. Defaults to `/data` in the container |
| `IMAGE_CACHE_ENABLED` | No | Defaults to enabled. Set to `false` to use TMDB images directly instead of caching them locally |
| `LOG_LEVEL` | No | `error`, `warn`, `info`, or `debug` |
| `OIDC_CLIENT_ID` | No | Enable OIDC when set with the matching secret and issuer |
| `OIDC_CLIENT_SECRET` | No | OIDC client secret |
| `OIDC_ISSUER_URL` | No | OIDC issuer URL |
| `OIDC_PROVIDER_NAME` | No | Login button label. Defaults to `SSO` |
| `OIDC_AUTO_REGISTER` | No | Defaults to `true` |
| `DISABLE_PASSWORD_LOGIN` | No | Set to `true` to hide email/password login when OIDC is configured |
See [`.env.example`](./.env.example) for the full list.
## Integrations
Sofa ships with two kinds of integrations (for now): incoming watch activity and outgoing import lists.
### Incoming watch activity
- Plex: logs completed watches through a webhook URL generated in Sofa. Requires an active [Plex Pass](https://www.plex.tv/plex-pass/) license.
- Jellyfin: works through the Jellyfin Webhook plugin.
- Emby: logs completed watches through webhooks. Requires Emby Server 4.7.9+ and an [Emby Premiere](https://emby.media/premiere.html) subscription.
These integrations are user-specific, so each user can connect their own media server account and watch history.
### Outgoing import lists
- Sonarr: expose your Sofa TV watchlist as a custom import list
- Radarr: expose your Sofa movie watchlist as a custom import list
## Development
For local development:
```bash
bun install
cp .env.example .env
bun run dev
```
Useful commands:
- `bun run test`
- `bun run lint`
- `bun run check-types`
- `bun run db:generate`
- `bun run db:migrate`
- `bun run db:seed`
## TMDB notice
This product uses the TMDB API but is not endorsed or certified by TMDB.
## License
[MIT](LICENSE)
@@ -2,7 +2,10 @@
import { IconPlus, IconStar } from "@tabler/icons-react"; import { IconPlus, IconStar } from "@tabler/icons-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { useProgress } from "@/components/navigation-progress";
import { resolveTitle } from "@/lib/actions/titles";
interface HeroBannerProps { interface HeroBannerProps {
tmdbId: number; tmdbId: number;
@@ -21,7 +24,18 @@ export function HeroBanner({
backdropPath, backdropPath,
voteAverage, voteAverage,
}: HeroBannerProps) { }: HeroBannerProps) {
const href = `/titles/tmdb-${tmdbId}-${type}`; const router = useRouter();
const progress = useProgress();
const [isPending, startTransition] = useTransition();
function handleNavigate() {
if (isPending) return;
progress.start();
startTransition(async () => {
const id = await resolveTitle(tmdbId, type);
if (id) router.push(`/titles/${id}`);
});
}
return ( return (
<div className="relative -mt-6 mr-[calc(-50vw+50%)] mb-4 ml-[calc(-50vw+50%)] animate-stagger-item overflow-hidden"> <div className="relative -mt-6 mr-[calc(-50vw+50%)] mb-4 ml-[calc(-50vw+50%)] animate-stagger-item overflow-hidden">
@@ -67,21 +81,28 @@ export function HeroBanner({
Trending today Trending today
</span> </span>
</div> </div>
<Link href={href} className="group/title"> <button
type="button"
className="group/title cursor-pointer text-left"
onClick={handleNavigate}
disabled={isPending}
>
<h2 className="text-balance font-display text-3xl tracking-tight transition-colors group-hover/title:text-primary sm:text-4xl"> <h2 className="text-balance font-display text-3xl tracking-tight transition-colors group-hover/title:text-primary sm:text-4xl">
{title} {title}
</h2> </h2>
</Link> </button>
<p className="mt-2 line-clamp-2 max-w-2xl text-muted-foreground text-sm"> <p className="mt-2 line-clamp-2 max-w-2xl text-muted-foreground text-sm">
{overview} {overview}
</p> </p>
<Link <button
href={href} type="button"
className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20" onClick={handleNavigate}
disabled={isPending}
className="mt-4 inline-flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-primary px-4 font-medium text-primary-foreground text-sm transition-shadow hover:shadow-md hover:shadow-primary/20 disabled:opacity-70"
> >
<IconPlus aria-hidden={true} className="size-4" /> <IconPlus aria-hidden={true} className="size-4" />
Add to Library Add to Library
</Link> </button>
</div> </div>
</div> </div>
</div> </div>
+2 -15
View File
@@ -4,25 +4,17 @@ import { notFound } from "next/navigation";
import { getSession } from "@/lib/auth/session"; import { getSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { persons } from "@/lib/db/schema"; import { persons } from "@/lib/db/schema";
import { import { getLocalFilmography, getOrFetchPerson } from "@/lib/services/person";
getLocalFilmography,
getOrFetchPerson,
getOrFetchPersonByTmdbId,
} from "@/lib/services/person";
import { getUserStatusesByTitleIds } from "@/lib/services/tracking"; import { getUserStatusesByTitleIds } from "@/lib/services/tracking";
import { FilmographyGrid } from "./_components/filmography-grid"; import { FilmographyGrid } from "./_components/filmography-grid";
import { PersonHero } from "./_components/person-hero"; import { PersonHero } from "./_components/person-hero";
const TMDB_PATTERN = /^tmdb-(\d+)$/;
export async function generateMetadata({ export async function generateMetadata({
params, params,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const { id } = await params; const { id } = await params;
if (TMDB_PATTERN.test(id)) return { title: "Sofa" };
const person = db.select().from(persons).where(eq(persons.id, id)).get(); const person = db.select().from(persons).where(eq(persons.id, id)).get();
if (!person) return { title: "Not Found — Sofa" }; if (!person) return { title: "Not Found — Sofa" };
@@ -38,13 +30,8 @@ export default async function PersonDetailPage({
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}) { }) {
const { id } = await params; const { id } = await params;
await getSession();
const tmdbMatch = TMDB_PATTERN.exec(id);
const person = tmdbMatch
? await getOrFetchPersonByTmdbId(Number(tmdbMatch[1]))
: await getOrFetchPerson(id);
const person = await getOrFetchPerson(id);
if (!person) notFound(); if (!person) notFound();
const filmography = getLocalFilmography(person.id); const filmography = getLocalFilmography(person.id);
+3 -18
View File
@@ -1,6 +1,6 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation"; import { notFound } from "next/navigation";
import { Suspense } from "react"; import { Suspense } from "react";
import { import {
RecommendationsSkeleton, RecommendationsSkeleton,
@@ -9,7 +9,7 @@ import {
import { getSession } from "@/lib/auth/session"; import { getSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { titles } from "@/lib/db/schema"; import { titles } from "@/lib/db/schema";
import { getTitleWithChildren, importTitle } from "@/lib/services/metadata"; import { getOrFetchTitle } from "@/lib/services/metadata";
import { getUserTitleInfo } from "@/lib/services/tracking"; import { getUserTitleInfo } from "@/lib/services/tracking";
import { tmdbImageUrl } from "@/lib/tmdb/image"; import { tmdbImageUrl } from "@/lib/tmdb/image";
import { getTitleThemeStyle } from "@/lib/utils/title-theme"; import { getTitleThemeStyle } from "@/lib/utils/title-theme";
@@ -23,16 +23,12 @@ import { TitleProvider } from "./_components/title-provider";
import { TitleRecommendations } from "./_components/title-recommendations"; import { TitleRecommendations } from "./_components/title-recommendations";
import { TitleSeasons } from "./_components/title-seasons"; import { TitleSeasons } from "./_components/title-seasons";
const TMDB_ID_PATTERN = /^tmdb-(\d+)-(movie|tv)$/;
export async function generateMetadata({ export async function generateMetadata({
params, params,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const { id } = await params; const { id } = await params;
if (TMDB_ID_PATTERN.test(id)) return { title: "Sofa" };
const title = db.select().from(titles).where(eq(titles.id, id)).get(); const title = db.select().from(titles).where(eq(titles.id, id)).get();
if (!title) return { title: "Not Found — Sofa" }; if (!title) return { title: "Not Found — Sofa" };
@@ -56,21 +52,10 @@ export default async function TitleDetailPage({
}) { }) {
const { id } = await params; const { id } = await params;
// TMDB ID resolution: tmdb-{id}-{type} → import + redirect
const tmdbMatch = TMDB_ID_PATTERN.exec(id);
if (tmdbMatch) {
const title = await importTitle(
Number(tmdbMatch[1]),
tmdbMatch[2] as "movie" | "tv",
);
if (!title) notFound();
redirect(`/titles/${title.id}`);
}
// Fetch title + user info in parallel // Fetch title + user info in parallel
const session = await getSession(); const session = await getSession();
const [result, userInfo] = await Promise.all([ const [result, userInfo] = await Promise.all([
getTitleWithChildren(id), getOrFetchTitle(id),
session ? getUserTitleInfo(session.user.id, id) : null, session ? getUserTitleInfo(session.user.id, id) : null,
]); ]);
if (!result) notFound(); if (!result) notFound();
+8 -2
View File
@@ -36,6 +36,8 @@ import { Kbd } from "@/components/ui/kbd";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useDebounce } from "@/hooks/use-debounce"; import { useDebounce } from "@/hooks/use-debounce";
import { useSearch } from "@/hooks/use-search"; import { useSearch } from "@/hooks/use-search";
import { resolvePerson } from "@/lib/actions/people";
import { resolveTitle } from "@/lib/actions/titles";
import { import {
commandPaletteOpenAtom, commandPaletteOpenAtom,
helpOpenAtom, helpOpenAtom,
@@ -118,9 +120,13 @@ export function CommandPalette() {
setCommandPaletteOpen(false); setCommandPaletteOpen(false);
progress.start(); progress.start();
if (result.type === "person") { if (result.type === "person") {
router.push(`/people/tmdb-${result.tmdbId}`); void resolvePerson(result.tmdbId).then((id) => {
if (id) router.push(`/people/${id}`);
});
} else { } else {
router.push(`/titles/tmdb-${result.tmdbId}-${result.type}`); void resolveTitle(result.tmdbId, result.type).then((id) => {
if (id) router.push(`/titles/${id}`);
});
} }
}, },
[router, setCommandPaletteOpen, progress], [router, setCommandPaletteOpen, progress],
+47 -23
View File
@@ -14,13 +14,16 @@ import {
import { type MotionStyle, type MotionValue, motion } from "motion/react"; import { type MotionStyle, type MotionValue, motion } from "motion/react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useProgress } from "@/components/navigation-progress";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useTiltEffect } from "@/hooks/use-tilt-effect"; import { useTiltEffect } from "@/hooks/use-tilt-effect";
import { resolveTitle } from "@/lib/actions/titles";
import { quickAddToWatchlist } from "@/lib/actions/watchlist"; import { quickAddToWatchlist } from "@/lib/actions/watchlist";
type TitleStatus = "watchlist" | "in_progress" | "completed"; type TitleStatus = "watchlist" | "in_progress" | "completed";
@@ -280,8 +283,30 @@ export function TitleCard({
userStatus, userStatus,
episodeProgress, episodeProgress,
}: TitleCardProps) { }: TitleCardProps) {
const href = id ? `/titles/${id}` : `/titles/tmdb-${tmdbId}-${type}`;
const tilt = useTiltEffect(); const tilt = useTiltEffect();
const router = useRouter();
const progress = useProgress();
const [isPending, startTransition] = useTransition();
const cardContent = (
<motion.div ref={tilt.ref} style={tilt.containerStyle} {...tilt.handlers}>
<CardInner
title={title}
type={type}
posterPath={posterPath}
releaseDate={releaseDate}
voteAverage={voteAverage}
userStatus={userStatus}
episodeProgress={episodeProgress}
tiltStyles={{
imageStyle: tilt.imageStyle,
glareBackground: tilt.glareBackground,
glareOpacity: tilt.glareOpacity,
}}
/>
</motion.div>
);
return ( return (
<div className="group relative"> <div className="group relative">
<QuickAddButton <QuickAddButton
@@ -289,28 +314,27 @@ export function TitleCard({
type={type as "movie" | "tv"} type={type as "movie" | "tv"}
userStatus={userStatus} userStatus={userStatus}
/> />
<Link href={href}> {id ? (
<motion.div <Link href={`/titles/${id}`}>{cardContent}</Link>
ref={tilt.ref} ) : (
style={tilt.containerStyle} <button
{...tilt.handlers} type="button"
disabled={isPending}
className={`w-full text-left ${isPending ? "pointer-events-none opacity-70" : "cursor-pointer"}`}
onClick={() => {
progress.start();
startTransition(async () => {
const resolvedId = await resolveTitle(
tmdbId,
type as "movie" | "tv",
);
if (resolvedId) router.push(`/titles/${resolvedId}`);
});
}}
> >
<CardInner {cardContent}
title={title} </button>
type={type} )}
posterPath={posterPath}
releaseDate={releaseDate}
voteAverage={voteAverage}
userStatus={userStatus}
episodeProgress={episodeProgress}
tiltStyles={{
imageStyle: tilt.imageStyle,
glareBackground: tilt.glareBackground,
glareOpacity: tilt.glareOpacity,
}}
/>
</motion.div>
</Link>
</div> </div>
); );
} }
+10
View File
@@ -0,0 +1,10 @@
"use server";
import { requireSession } from "@/lib/auth/session";
import { getOrFetchPersonByTmdbId } from "@/lib/services/person";
export async function resolvePerson(tmdbId: number): Promise<string | null> {
await requireSession();
const person = await getOrFetchPersonByTmdbId(tmdbId);
return person?.id ?? null;
}
+10
View File
@@ -5,6 +5,7 @@ import { z } from "zod";
import { requireSession } from "@/lib/auth/session"; import { requireSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { episodes } from "@/lib/db/schema"; import { episodes } from "@/lib/db/schema";
import { getOrFetchTitleByTmdbId } from "@/lib/services/metadata";
import { import {
logEpisodeWatch, logEpisodeWatch,
logEpisodeWatchBatch, logEpisodeWatchBatch,
@@ -22,6 +23,15 @@ async function getSessionUserId() {
return session.user.id; return session.user.id;
} }
export async function resolveTitle(
tmdbId: number,
type: "movie" | "tv",
): Promise<string | null> {
await requireSession();
const title = await getOrFetchTitleByTmdbId(tmdbId, type);
return title?.id ?? null;
}
export async function updateTitleStatus( export async function updateTitleStatus(
titleId: string, titleId: string,
status: "in_progress" | null, status: "in_progress" | null,
+2 -2
View File
@@ -11,7 +11,7 @@ import {
type HistoryBucket, type HistoryBucket,
type TimePeriod, type TimePeriod,
} from "@/lib/services/discovery"; } from "@/lib/services/discovery";
import { importTitle } from "@/lib/services/metadata"; import { getOrFetchTitleByTmdbId } from "@/lib/services/metadata";
import { import {
getEpisodeProgressByTmdbIds, getEpisodeProgressByTmdbIds,
getUserStatusesByTmdbIds, getUserStatusesByTmdbIds,
@@ -41,7 +41,7 @@ export async function quickAddToWatchlist(
const session = await requireSession(); const session = await requireSession();
const userId = session.user.id; const userId = session.user.id;
const title = await importTitle(tmdbId, type); const title = await getOrFetchTitleByTmdbId(tmdbId, type);
if (!title) throw new Error("Failed to import title"); if (!title) throw new Error("Failed to import title");
const existing = db const existing = db
+24 -20
View File
@@ -20,8 +20,26 @@ export async function refreshAvailability(titleId: string) {
const now = new Date(); const now = new Date();
const offerTypes = ["flatrate", "rent", "buy", "free", "ads"] as const; const offerTypes = ["flatrate", "rent", "buy", "free", "ads"] as const;
// Collect all offer rows, then batch insert in a single transaction
const allOfferRows: (typeof availabilityOffers.$inferInsert)[] = [];
for (const offerType of offerTypes) {
const providers = us[offerType];
if (!providers) continue;
for (const p of providers) {
allOfferRows.push({
titleId,
region: "US",
providerId: p.provider_id,
providerName: p.provider_name,
logoPath: p.logo_path,
offerType,
link: us.link ?? null,
lastFetchedAt: now,
});
}
}
db.transaction((tx) => { db.transaction((tx) => {
// Delete existing offers for this title+region
tx.delete(availabilityOffers) tx.delete(availabilityOffers)
.where( .where(
and( and(
@@ -31,25 +49,11 @@ export async function refreshAvailability(titleId: string) {
) )
.run(); .run();
for (const offerType of offerTypes) { if (allOfferRows.length > 0) {
const providers = us[offerType]; tx.insert(availabilityOffers)
if (!providers) continue; .values(allOfferRows)
.onConflictDoNothing()
for (const p of providers) { .run();
tx.insert(availabilityOffers)
.values({
titleId,
region: "US",
providerId: p.provider_id,
providerName: p.provider_name,
logoPath: p.logo_path,
offerType,
link: us.link ?? null,
lastFetchedAt: now,
})
.onConflictDoNothing()
.run();
}
} }
}); });
+108 -136
View File
@@ -1,4 +1,4 @@
import { eq, inArray } from "drizzle-orm"; import { eq, inArray, sql } from "drizzle-orm";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { persons, titleCast, titles } from "@/lib/db/schema"; import { persons, titleCast, titles } from "@/lib/db/schema";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
@@ -119,73 +119,59 @@ export async function refreshCredits(titleId: string) {
]; ];
const personIds = batchUpsertPersons(allPeople); const personIds = batchUpsertPersons(allPeople);
// Batch insert titleCast rows // Collect all titleCast rows (cast + crew) and batch insert
db.transaction((tx) => { const now = new Date();
const now = new Date(); const allCastRows: (typeof titleCast.$inferInsert)[] = [];
for (let i = 0; i < castSlice.length; i++) { for (let i = 0; i < castSlice.length; i++) {
const c = castSlice[i]; const c = castSlice[i];
const personId = personIds.get(c.id); const personId = personIds.get(c.id);
if (!personId) continue; if (!personId) continue;
tx.insert(titleCast) allCastRows.push({
.values({ titleId,
titleId, personId,
personId, character: c.character,
character: c.character, department: "Acting",
department: "Acting", job: null,
job: null, displayOrder: i,
displayOrder: i, episodeCount: null,
episodeCount: null, lastFetchedAt: now,
lastFetchedAt: now, });
}) }
.onConflictDoUpdate({ let crewOrder = 100;
target: [ for (const c of notableCrew) {
titleCast.titleId, const personId = personIds.get(c.id);
titleCast.personId, if (!personId) continue;
titleCast.department, allCastRows.push({
titleCast.character, titleId,
], personId,
set: { character: null,
job: null, department: c.department,
displayOrder: i, job: c.job,
episodeCount: null, displayOrder: crewOrder,
lastFetchedAt: now, episodeCount: null,
}, lastFetchedAt: now,
}) });
.run(); crewOrder++;
} }
let crewOrder = 100; if (allCastRows.length > 0) {
for (const c of notableCrew) { db.insert(titleCast)
const personId = personIds.get(c.id); .values(allCastRows)
if (!personId) continue; .onConflictDoUpdate({
tx.insert(titleCast) target: [
.values({ titleCast.titleId,
titleId, titleCast.personId,
personId, titleCast.department,
character: null, titleCast.character,
department: c.department, ],
job: c.job, set: {
displayOrder: crewOrder, job: sql`excluded.job`,
episodeCount: null, displayOrder: sql`excluded.displayOrder`,
lastFetchedAt: now, episodeCount: sql`excluded.episodeCount`,
}) lastFetchedAt: sql`excluded.lastFetchedAt`,
.onConflictDoUpdate({ },
target: [ })
titleCast.titleId, .run();
titleCast.personId, }
titleCast.department,
titleCast.character,
],
set: {
job: c.job,
displayOrder: crewOrder,
episodeCount: null,
lastFetchedAt: now,
},
})
.run();
crewOrder++;
}
});
} else { } else {
const credits = await getTvAggregateCredits(title.tmdbId); const credits = await getTvAggregateCredits(title.tmdbId);
const castSlice = credits.cast.slice(0, 20); const castSlice = credits.cast.slice(0, 20);
@@ -228,74 +214,60 @@ export async function refreshCredits(titleId: string) {
]; ];
const personIds = batchUpsertPersons(allPeople); const personIds = batchUpsertPersons(allPeople);
// Batch insert titleCast rows // Collect all titleCast rows (cast + crew) and batch insert
db.transaction((tx) => { const now = new Date();
const now = new Date(); const allCastRows: (typeof titleCast.$inferInsert)[] = [];
for (let i = 0; i < castSlice.length; i++) { for (let i = 0; i < castSlice.length; i++) {
const c = castSlice[i]; const c = castSlice[i];
const personId = personIds.get(c.id); const personId = personIds.get(c.id);
if (!personId) continue; if (!personId) continue;
const character = c.roles?.[0]?.character ?? null; const character = c.roles?.[0]?.character ?? null;
tx.insert(titleCast) allCastRows.push({
.values({ titleId,
titleId, personId,
personId, character,
character, department: "Acting",
department: "Acting", job: null,
job: null, displayOrder: i,
displayOrder: i, episodeCount: c.total_episode_count,
episodeCount: c.total_episode_count, lastFetchedAt: now,
lastFetchedAt: now, });
}) }
.onConflictDoUpdate({ let crewOrder = 100;
target: [ for (const c of notableCrew) {
titleCast.titleId, const personId = personIds.get(c.person.id);
titleCast.personId, if (!personId) continue;
titleCast.department, allCastRows.push({
titleCast.character, titleId,
], personId,
set: { character: null,
job: null, department: c.person.department,
displayOrder: i, job: c.job,
episodeCount: c.total_episode_count, displayOrder: crewOrder,
lastFetchedAt: now, episodeCount: c.episodeCount,
}, lastFetchedAt: now,
}) });
.run(); crewOrder++;
} }
let crewOrder = 100; if (allCastRows.length > 0) {
for (const c of notableCrew) { db.insert(titleCast)
const personId = personIds.get(c.person.id); .values(allCastRows)
if (!personId) continue; .onConflictDoUpdate({
tx.insert(titleCast) target: [
.values({ titleCast.titleId,
titleId, titleCast.personId,
personId, titleCast.department,
character: null, titleCast.character,
department: c.person.department, ],
job: c.job, set: {
displayOrder: crewOrder, job: sql`excluded.job`,
episodeCount: c.episodeCount, displayOrder: sql`excluded.displayOrder`,
lastFetchedAt: now, episodeCount: sql`excluded.episodeCount`,
}) lastFetchedAt: sql`excluded.lastFetchedAt`,
.onConflictDoUpdate({ },
target: [ })
titleCast.titleId, .run();
titleCast.personId, }
titleCast.department,
titleCast.character,
],
set: {
job: c.job,
displayOrder: crewOrder,
episodeCount: c.episodeCount,
lastFetchedAt: now,
},
})
.run();
crewOrder++;
}
});
} }
log.debug(`Credits refreshed for "${title.title}"`); log.debug(`Credits refreshed for "${title.title}"`);
+60 -42
View File
@@ -145,22 +145,13 @@ export async function cacheImagesForTitle(titleId: string) {
const title = db.select().from(titles).where(eq(titles.id, titleId)).get(); const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
if (!title) return; if (!title) return;
const tasks: Promise<unknown>[] = []; // Collect all candidate images, then check cache in parallel
const candidates: { imgPath: string; category: ImageCategory }[] = [];
if (title.posterPath)
candidates.push({ imgPath: title.posterPath, category: "posters" });
if (title.backdropPath)
candidates.push({ imgPath: title.backdropPath, category: "backdrops" });
if (
title.posterPath &&
!(await isImageCached("posters", path.basename(title.posterPath)))
) {
tasks.push(downloadAndCacheImage(title.posterPath, "posters"));
}
if (
title.backdropPath &&
!(await isImageCached("backdrops", path.basename(title.backdropPath)))
) {
tasks.push(downloadAndCacheImage(title.backdropPath, "backdrops"));
}
// Season posters
if (title.type === "tv") { if (title.type === "tv") {
const allSeasons = db const allSeasons = db
.select() .select()
@@ -168,15 +159,22 @@ export async function cacheImagesForTitle(titleId: string) {
.where(eq(seasons.titleId, titleId)) .where(eq(seasons.titleId, titleId))
.all(); .all();
for (const s of allSeasons) { for (const s of allSeasons) {
if ( if (s.posterPath)
s.posterPath && candidates.push({ imgPath: s.posterPath, category: "posters" });
!(await isImageCached("posters", path.basename(s.posterPath)))
) {
tasks.push(downloadAndCacheImage(s.posterPath, "posters"));
}
} }
} }
// Parallel cache checks instead of sequential awaits
const checks = await Promise.all(
candidates.map(async (c) => ({
...c,
cached: await isImageCached(c.category, path.basename(c.imgPath)),
})),
);
const tasks = checks
.filter((c) => !c.cached)
.map((c) => downloadAndCacheImage(c.imgPath, c.category));
if (tasks.length > 0) { if (tasks.length > 0) {
log.debug(`Caching ${tasks.length} images for title ${titleId}`); log.debug(`Caching ${tasks.length} images for title ${titleId}`);
} }
@@ -200,15 +198,20 @@ export async function cacheEpisodeStills(titleId: string) {
.where(inArray(episodes.seasonId, seasonIds)) .where(inArray(episodes.seasonId, seasonIds))
.all(); .all();
const tasks: Promise<unknown>[] = []; const epsWithStills = allEps.filter(
for (const ep of allEps) { (ep): ep is typeof ep & { stillPath: string } => ep.stillPath != null,
if ( );
ep.stillPath && // Parallel cache checks instead of sequential awaits
!(await isImageCached("stills", path.basename(ep.stillPath))) const checks = await Promise.all(
) { epsWithStills.map(async (ep) => ({
tasks.push(downloadAndCacheImage(ep.stillPath, "stills")); stillPath: ep.stillPath,
} cached: await isImageCached("stills", path.basename(ep.stillPath)),
} })),
);
const tasks = checks
.filter((c) => !c.cached)
.map((c) => downloadAndCacheImage(c.stillPath, "stills"));
await Promise.allSettled(tasks); await Promise.allSettled(tasks);
} }
@@ -219,17 +222,24 @@ export async function cacheProviderLogos(titleId: string) {
.where(eq(availabilityOffers.titleId, titleId)) .where(eq(availabilityOffers.titleId, titleId))
.all(); .all();
const tasks: Promise<unknown>[] = []; // Deduplicate and parallel cache checks
const seen = new Set<string>(); const uniqueLogos = new Map<string, string>();
for (const offer of offers) { for (const offer of offers) {
if (offer.logoPath) { if (offer.logoPath) {
const basename = path.basename(offer.logoPath); const basename = path.basename(offer.logoPath);
if (!seen.has(basename) && !(await isImageCached("logos", basename))) { if (!uniqueLogos.has(basename)) uniqueLogos.set(basename, offer.logoPath);
seen.add(basename);
tasks.push(downloadAndCacheImage(offer.logoPath, "logos"));
}
} }
} }
const checks = await Promise.all(
[...uniqueLogos.entries()].map(async ([basename, logoPath]) => ({
logoPath,
cached: await isImageCached("logos", basename),
})),
);
const tasks = checks
.filter((c) => !c.cached)
.map((c) => downloadAndCacheImage(c.logoPath, "logos"));
await Promise.allSettled(tasks); await Promise.allSettled(tasks);
} }
@@ -241,17 +251,25 @@ export async function cacheProfilePhotos(titleId: string) {
.where(eq(titleCast.titleId, titleId)) .where(eq(titleCast.titleId, titleId))
.all(); .all();
const tasks: Promise<unknown>[] = []; // Deduplicate and parallel cache checks
const seen = new Set<string>(); const uniqueProfiles = new Map<string, string>();
for (const row of castRows) { for (const row of castRows) {
if (row.profilePath) { if (row.profilePath) {
const basename = path.basename(row.profilePath); const basename = path.basename(row.profilePath);
if (!seen.has(basename) && !(await isImageCached("profiles", basename))) { if (!uniqueProfiles.has(basename))
seen.add(basename); uniqueProfiles.set(basename, row.profilePath);
tasks.push(downloadAndCacheImage(row.profilePath, "profiles"));
}
} }
} }
const checks = await Promise.all(
[...uniqueProfiles.entries()].map(async ([basename, profilePath]) => ({
profilePath,
cached: await isImageCached("profiles", basename),
})),
);
const tasks = checks
.filter((c) => !c.cached)
.map((c) => downloadAndCacheImage(c.profilePath, "profiles"));
if (tasks.length > 0) { if (tasks.length > 0) {
log.debug(`Caching ${tasks.length} profile photos for title ${titleId}`); log.debug(`Caching ${tasks.length} profile photos for title ${titleId}`);
} }
+23 -18
View File
@@ -88,27 +88,32 @@ export async function getSonarrList(
) )
.all(); .all();
const result: { TvdbId: number; Title: string }[] = []; // Resolve missing TVDB IDs in parallel instead of sequentially
const needsResolution = rows.filter((r) => r.tvdbId == null);
for (const row of rows) { if (needsResolution.length > 0) {
let { tvdbId } = row; const resolved = await Promise.all(
needsResolution.map(async (row) => {
if (tvdbId == null) { try {
try { const externalIds = await getTvExternalIds(row.tmdbId);
const externalIds = await getTvExternalIds(row.tmdbId); return { row, tvdbId: externalIds.tvdb_id };
tvdbId = externalIds.tvdb_id; } catch (err) {
log.warn(`Failed to resolve TVDB ID for TMDB ${row.tmdbId}:`, err);
return { row, tvdbId: null };
}
}),
);
// Batch update resolved IDs in a single transaction
db.transaction((tx) => {
for (const { row, tvdbId } of resolved) {
if (tvdbId != null) { if (tvdbId != null) {
db.update(titles).set({ tvdbId }).where(eq(titles.id, row.id)).run(); row.tvdbId = tvdbId;
tx.update(titles).set({ tvdbId }).where(eq(titles.id, row.id)).run();
} }
} catch (err) {
log.warn(`Failed to resolve TVDB ID for TMDB ${row.tmdbId}:`, err);
} }
} });
if (tvdbId != null) {
result.push({ TvdbId: tvdbId, Title: row.title });
}
} }
return result; return rows
.filter((r): r is typeof r & { tvdbId: number } => r.tvdbId != null)
.map((r) => ({ TvdbId: r.tvdbId, Title: r.title }));
} }
+68 -52
View File
@@ -1,4 +1,4 @@
import { eq, inArray } from "drizzle-orm"; import { eq, inArray, sql } from "drizzle-orm";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { import {
availabilityOffers, availabilityOffers,
@@ -48,7 +48,7 @@ const log = createLogger("metadata");
* Insert a title row, or return the existing one if a concurrent insert won the race. * Insert a title row, or return the existing one if a concurrent insert won the race.
* Catches SQLITE_CONSTRAINT_UNIQUE and falls back to a SELECT. * Catches SQLITE_CONSTRAINT_UNIQUE and falls back to a SELECT.
*/ */
function insertTitleOrGet(values: typeof titles.$inferInsert, tmdbId: number) { function upsertTitle(values: typeof titles.$inferInsert, tmdbId: number) {
try { try {
return db.insert(titles).values(values).returning().get(); return db.insert(titles).values(values).returning().get();
} catch (err: unknown) { } catch (err: unknown) {
@@ -66,19 +66,21 @@ function insertTitleOrGet(values: typeof titles.$inferInsert, tmdbId: number) {
function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) { function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) {
if (tmdbGenres.length === 0) return; if (tmdbGenres.length === 0) return;
for (const g of tmdbGenres) { db.transaction((tx) => {
db.insert(genres) for (const g of tmdbGenres) {
.values({ id: g.id, name: g.name }) tx.insert(genres)
.onConflictDoUpdate({ target: genres.id, set: { name: g.name } }) .values({ id: g.id, name: g.name })
.run(); .onConflictDoUpdate({ target: genres.id, set: { name: g.name } })
} .run();
db.delete(titleGenres).where(eq(titleGenres.titleId, titleId)).run(); }
for (const g of tmdbGenres) { tx.delete(titleGenres).where(eq(titleGenres.titleId, titleId)).run();
db.insert(titleGenres) for (const g of tmdbGenres) {
.values({ titleId, genreId: g.id }) tx.insert(titleGenres)
.onConflictDoNothing() .values({ titleId, genreId: g.id })
.run(); .onConflictDoNothing()
} .run();
}
});
} }
/** @internal */ /** @internal */
@@ -99,12 +101,12 @@ export function extractTvContentRating(show: TmdbTvDetails): string | null {
return us?.rating || null; return us?.rating || null;
} }
type ImportResult = ReturnType<typeof _importTitle>; type ImportResult = ReturnType<typeof _getOrFetchTitleByTmdbId>;
/** In-flight import promises keyed by tmdbId — coalesces concurrent calls */ /** In-flight import promises keyed by tmdbId — coalesces concurrent calls */
const inflightImports = new Map<number, ImportResult>(); const inflightImports = new Map<number, ImportResult>();
export function importTitle( export function getOrFetchTitleByTmdbId(
tmdbId: number, tmdbId: number,
type: "movie" | "tv", type: "movie" | "tv",
): ImportResult { ): ImportResult {
@@ -114,14 +116,14 @@ export function importTitle(
return inflight; return inflight;
} }
const promise = _importTitle(tmdbId, type).finally(() => { const promise = _getOrFetchTitleByTmdbId(tmdbId, type).finally(() => {
inflightImports.delete(tmdbId); inflightImports.delete(tmdbId);
}) as ImportResult; }) as ImportResult;
inflightImports.set(tmdbId, promise); inflightImports.set(tmdbId, promise);
return promise; return promise;
} }
async function _importTitle(tmdbId: number, type: "movie" | "tv") { async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
log.debug(`Importing ${type} TMDB ${tmdbId}`); log.debug(`Importing ${type} TMDB ${tmdbId}`);
const existing = db const existing = db
@@ -191,7 +193,7 @@ async function _importTitle(tmdbId: number, type: "movie" | "tv") {
if (type === "movie") { if (type === "movie") {
const movie = await getMovieDetails(tmdbId); const movie = await getMovieDetails(tmdbId);
const row = insertTitleOrGet( const row = upsertTitle(
{ {
tmdbId: movie.id, tmdbId: movie.id,
type: "movie", type: "movie",
@@ -237,7 +239,7 @@ async function _importTitle(tmdbId: number, type: "movie" | "tv") {
} }
const show = await getTvDetails(tmdbId); const show = await getTvDetails(tmdbId);
const row = insertTitleOrGet( const row = upsertTitle(
{ {
tmdbId: show.id, tmdbId: show.id,
tvdbId: show.external_ids?.tvdb_id ?? null, tvdbId: show.external_ids?.tvdb_id ?? null,
@@ -401,28 +403,33 @@ export async function refreshTvChildren(
.returning() .returning()
.get(); .get();
for (const ep of seasonData.episodes) { // Batch all episode upserts in a single transaction per season
db.insert(episodes) if (seasonData.episodes.length > 0) {
.values({ db.transaction((tx) => {
seasonId: seasonRow.id, for (const ep of seasonData.episodes) {
episodeNumber: ep.episode_number, tx.insert(episodes)
name: ep.name, .values({
overview: ep.overview, seasonId: seasonRow.id,
stillPath: ep.still_path, episodeNumber: ep.episode_number,
airDate: ep.air_date, name: ep.name,
runtimeMinutes: ep.runtime, overview: ep.overview,
}) stillPath: ep.still_path,
.onConflictDoUpdate({ airDate: ep.air_date,
target: [episodes.seasonId, episodes.episodeNumber], runtimeMinutes: ep.runtime,
set: { })
name: ep.name, .onConflictDoUpdate({
overview: ep.overview, target: [episodes.seasonId, episodes.episodeNumber],
stillPath: ep.still_path, set: {
airDate: ep.air_date, name: ep.name,
runtimeMinutes: ep.runtime, overview: ep.overview,
}, stillPath: ep.still_path,
}) airDate: ep.air_date,
.run(); runtimeMinutes: ep.runtime,
},
})
.run();
}
});
} }
} catch (err) { } catch (err) {
// Skip this season and continue with the rest — partial data is // Skip this season and continue with the rest — partial data is
@@ -534,25 +541,34 @@ export async function refreshRecommendations(titleId: string) {
for (const f of fallbacks) titleIdMap.set(f.tmdbId, f.id); for (const f of fallbacks) titleIdMap.set(f.tmdbId, f.id);
} }
// Upsert all recommendation rows // Batch upsert all recommendation rows (N inserts → 1)
for (const item of allItems) { const recRows = allItems
const recTitleId = titleIdMap.get(item.result.id); .map((item) => {
if (!recTitleId) continue; const recTitleId = titleIdMap.get(item.result.id);
tx.insert(titleRecommendations) if (!recTitleId) return null;
.values({ return {
titleId, titleId,
recommendedTitleId: recTitleId, recommendedTitleId: recTitleId,
source: item.source, source: item.source,
rank: item.rank, rank: item.rank,
lastFetchedAt: now, lastFetchedAt: now,
}) };
})
.filter((r): r is NonNullable<typeof r> => r !== null);
if (recRows.length > 0) {
tx.insert(titleRecommendations)
.values(recRows)
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [ target: [
titleRecommendations.titleId, titleRecommendations.titleId,
titleRecommendations.recommendedTitleId, titleRecommendations.recommendedTitleId,
titleRecommendations.source, titleRecommendations.source,
], ],
set: { rank: item.rank, lastFetchedAt: now }, set: {
rank: sql`excluded.rank`,
lastFetchedAt: sql`excluded.lastFetchedAt`,
},
}) })
.run(); .run();
} }
@@ -742,7 +758,7 @@ function readAvailability(
})); }));
} }
export async function getTitleWithChildren(id: string): Promise<{ export async function getOrFetchTitle(id: string): Promise<{
title: ResolvedTitle; title: ResolvedTitle;
seasons: Season[]; seasons: Season[];
needsHydration: boolean; needsHydration: boolean;
+30 -56
View File
@@ -77,16 +77,15 @@ export function logEpisodeWatch(
.values({ userId, episodeId, watchedAt: now, source }) .values({ userId, episodeId, watchedAt: now, source })
.run(); .run();
// Find the title for this episode // Find the title for this episode (single JOIN instead of 2 queries)
const ep = db.select().from(episodes).where(eq(episodes.id, episodeId)).get(); const row = db
if (!ep) return; .select({ titleId: seasons.titleId })
const season = db .from(episodes)
.select() .innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.from(seasons) .where(eq(episodes.id, episodeId))
.where(eq(seasons.id, ep.seasonId))
.get(); .get();
if (!season) return; if (!row) return;
const { titleId } = season; const { titleId } = row;
// Auto-set status to in_progress if not set // Auto-set status to in_progress if not set
const existing = db const existing = db
@@ -231,22 +230,14 @@ export function markAllEpisodesWatched(
if (!title || title.type !== "tv") return; if (!title || title.type !== "tv") return;
const now = new Date(); const now = new Date();
const allSeasons = db // Single JOIN instead of seasons → episodes chain (2 queries → 1)
.select() const allEps = db
.from(seasons) .select({ id: episodes.id })
.from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(eq(seasons.titleId, titleId)) .where(eq(seasons.titleId, titleId))
.all(); .all();
const seasonIds = allSeasons.map((s) => s.id);
const allEps =
seasonIds.length > 0
? db
.select()
.from(episodes)
.where(inArray(episodes.seasonId, seasonIds))
.all()
: [];
const epIds = allEps.map((ep) => ep.id); const epIds = allEps.map((ep) => ep.id);
const existingWatches = const existingWatches =
epIds.length > 0 epIds.length > 0
@@ -279,19 +270,12 @@ export function markAllEpisodesWatched(
} }
function checkAllEpisodesWatched(userId: string, titleId: string) { function checkAllEpisodesWatched(userId: string, titleId: string) {
const allSeasons = db // Single JOIN instead of seasons → episodes chain (2 queries → 1)
.select()
.from(seasons)
.where(eq(seasons.titleId, titleId))
.all();
if (allSeasons.length === 0) return;
const seasonIds = allSeasons.map((s) => s.id);
const allEps = db const allEps = db
.select() .select({ id: episodes.id })
.from(episodes) .from(episodes)
.where(inArray(episodes.seasonId, seasonIds)) .innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(eq(seasons.titleId, titleId))
.all(); .all();
const totalEpisodes = allEps.length; const totalEpisodes = allEps.length;
@@ -327,14 +311,13 @@ export function unwatchEpisode(userId: string, episodeId: string) {
.run(); .run();
// Find parent title and downgrade from completed to in_progress // Find parent title and downgrade from completed to in_progress
const ep = db.select().from(episodes).where(eq(episodes.id, episodeId)).get(); const row = db
if (!ep) return; .select({ titleId: seasons.titleId })
const season = db .from(episodes)
.select() .innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.from(seasons) .where(eq(episodes.id, episodeId))
.where(eq(seasons.id, ep.seasonId))
.get(); .get();
if (!season) return; if (!row) return;
const existing = db const existing = db
.select() .select()
@@ -342,13 +325,13 @@ export function unwatchEpisode(userId: string, episodeId: string) {
.where( .where(
and( and(
eq(userTitleStatus.userId, userId), eq(userTitleStatus.userId, userId),
eq(userTitleStatus.titleId, season.titleId), eq(userTitleStatus.titleId, row.titleId),
), ),
) )
.get(); .get();
if (existing?.status === "completed") { if (existing?.status === "completed") {
setTitleStatus(userId, season.titleId, "in_progress"); setTitleStatus(userId, row.titleId, "in_progress");
} }
} }
@@ -545,23 +528,14 @@ export function getUserTitleInfo(userId: string, titleId: string) {
) )
.get(); .get();
// Batch fetch all episode IDs for this title // Single JOIN instead of seasons → episodes chain (2 queries → 1)
const titleSeasons = db const allEps = db
.select() .select({ id: episodes.id })
.from(seasons) .from(episodes)
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
.where(eq(seasons.titleId, titleId)) .where(eq(seasons.titleId, titleId))
.all(); .all();
const seasonIds = titleSeasons.map((s) => s.id);
const allEps =
seasonIds.length > 0
? db
.select()
.from(episodes)
.where(inArray(episodes.seasonId, seasonIds))
.all()
: [];
const epIds = allEps.map((ep) => ep.id); const epIds = allEps.map((ep) => ep.id);
// Batch fetch all watches for these episodes // Batch fetch all watches for these episodes
+3 -3
View File
@@ -10,7 +10,7 @@ import {
} from "@/lib/db/schema"; } from "@/lib/db/schema";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { findByExternalId, searchTv } from "@/lib/tmdb/client"; import { findByExternalId, searchTv } from "@/lib/tmdb/client";
import { importTitle } from "./metadata"; import { getOrFetchTitleByTmdbId } from "./metadata";
import { logEpisodeWatch, logMovieWatch } from "./tracking"; import { logEpisodeWatch, logMovieWatch } from "./tracking";
const log = createLogger("webhooks"); const log = createLogger("webhooks");
@@ -330,7 +330,7 @@ export async function processWebhook(
return { status: "error", message: "Could not resolve TMDB ID" }; return { status: "error", message: "Could not resolve TMDB ID" };
} }
const title = await importTitle(tmdbId, "movie"); const title = await getOrFetchTitleByTmdbId(tmdbId, "movie");
if (!title) { if (!title) {
logEvent(connectionId, event, "error", "Failed to import movie"); logEvent(connectionId, event, "error", "Failed to import movie");
return { status: "error", message: "Failed to import movie" }; return { status: "error", message: "Failed to import movie" };
@@ -361,7 +361,7 @@ export async function processWebhook(
return { status: "error", message: "Could not resolve episode" }; return { status: "error", message: "Could not resolve episode" };
} }
const title = await importTitle(resolved.showTmdbId, "tv"); const title = await getOrFetchTitleByTmdbId(resolved.showTmdbId, "tv");
if (!title) { if (!title) {
logEvent(connectionId, event, "error", "Failed to import TV show"); logEvent(connectionId, event, "error", "Failed to import TV show");
return { status: "error", message: "Failed to import TV show" }; return { status: "error", message: "Failed to import TV show" };
+3 -3
View File
@@ -40,7 +40,7 @@ import {
userTitleStatus, userTitleStatus,
} from "@/lib/db/schema"; } from "@/lib/db/schema";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { importTitle } from "@/lib/services/metadata"; import { getOrFetchTitleByTmdbId } from "@/lib/services/metadata";
import { setSetting } from "@/lib/services/settings"; import { setSetting } from "@/lib/services/settings";
const log = createLogger("seed"); const log = createLogger("seed");
@@ -164,7 +164,7 @@ async function seedForUser(userId: string) {
for (const movie of MOVIES) { for (const movie of MOVIES) {
try { try {
log.info(` Importing movie: ${movie.name} (TMDB ${movie.tmdbId})`); log.info(` Importing movie: ${movie.name} (TMDB ${movie.tmdbId})`);
const title = await importTitle(movie.tmdbId, "movie"); const title = await getOrFetchTitleByTmdbId(movie.tmdbId, "movie");
if (title) { if (title) {
movieTitles.push({ movieTitles.push({
id: title.id, id: title.id,
@@ -184,7 +184,7 @@ async function seedForUser(userId: string) {
for (const show of TV_SHOWS) { for (const show of TV_SHOWS) {
try { try {
log.info(` Importing TV show: ${show.name} (TMDB ${show.tmdbId})`); log.info(` Importing TV show: ${show.name} (TMDB ${show.tmdbId})`);
const title = await importTitle(show.tmdbId, "tv"); const title = await getOrFetchTitleByTmdbId(show.tmdbId, "tv");
if (title) { if (title) {
tvTitles.push({ id: title.id, tmdbId: show.tmdbId, name: show.name }); tvTitles.push({ id: title.id, tmdbId: show.tmdbId, name: show.name });
} }