mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 05:05:38 -04:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user