diff --git a/app/(pages)/explore/genre-browser.tsx b/app/(pages)/explore/genre-browser.tsx index db6d180..31dec2b 100644 --- a/app/(pages)/explore/genre-browser.tsx +++ b/app/(pages)/explore/genre-browser.tsx @@ -1,8 +1,7 @@ "use client"; import { motion } from "motion/react"; -import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { TitleCardSkeleton } from "@/components/skeletons"; import { TitleCard } from "@/components/title-card"; @@ -41,7 +40,6 @@ const staggerItem = { }; export function GenreBrowser({ movieGenres, tvGenres }: GenreBrowserProps) { - const router = useRouter(); const [mediaType, setMediaType] = useState<"movie" | "tv">("movie"); const [selectedGenre, setSelectedGenre] = useState(null); const [results, setResults] = useState([]); @@ -82,21 +80,6 @@ export function GenreBrowser({ movieGenres, tvGenres }: GenreBrowserProps) { }; }, [selectedGenre, mediaType]); - const handleImport = useCallback( - async (tmdbId: number, type: "movie" | "tv") => { - const res = await fetch("/api/titles/import", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tmdbId, type }), - }); - const data = await res.json(); - if (data.id) { - router.push(`/titles/${data.id}`); - } - }, - [router], - ); - return (
@@ -173,7 +156,7 @@ export function GenreBrowser({ movieGenres, tvGenres }: GenreBrowserProps) { posterPath={r.posterPath} releaseDate={r.releaseDate} voteAverage={r.voteAverage} - onImport={() => handleImport(r.tmdbId, r.type)} + href={`/titles/tmdb-${r.tmdbId}-${r.type}`} /> ))} diff --git a/app/(pages)/explore/hero-banner.tsx b/app/(pages)/explore/hero-banner.tsx index e049df4..79e9e16 100644 --- a/app/(pages)/explore/hero-banner.tsx +++ b/app/(pages)/explore/hero-banner.tsx @@ -1,10 +1,9 @@ "use client"; -import { IconLoader2, IconPlus, IconStar } from "@tabler/icons-react"; +import { IconPlus, IconStar } from "@tabler/icons-react"; import { motion } from "motion/react"; import Image from "next/image"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; +import Link from "next/link"; interface HeroBannerProps { tmdbId: number; @@ -23,25 +22,7 @@ export function HeroBanner({ backdropPath, voteAverage, }: HeroBannerProps) { - const router = useRouter(); - const [importing, setImporting] = useState(false); - - async function handleImport() { - setImporting(true); - try { - const res = await fetch("/api/titles/import", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tmdbId, type }), - }); - const data = await res.json(); - if (data.id) { - router.push(`/titles/${data.id}`); - } - } finally { - setImporting(false); - } - } + const href = `/titles/tmdb-${tmdbId}-${type}`; return ( {overview}

- +
diff --git a/app/(pages)/explore/title-row.tsx b/app/(pages)/explore/title-row.tsx index f693f0d..f647791 100644 --- a/app/(pages)/explore/title-row.tsx +++ b/app/(pages)/explore/title-row.tsx @@ -1,8 +1,6 @@ "use client"; import { motion } from "motion/react"; -import { useRouter } from "next/navigation"; -import { useCallback } from "react"; import { TitleCard } from "@/components/title-card"; interface TitleRowItem { @@ -36,23 +34,6 @@ const staggerItem = { }; export function TitleRow({ heading, icon, items }: TitleRowProps) { - const router = useRouter(); - - const handleImport = useCallback( - async (tmdbId: number, type: "movie" | "tv") => { - const res = await fetch("/api/titles/import", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tmdbId, type }), - }); - const data = await res.json(); - if (data.id) { - router.push(`/titles/${data.id}`); - } - }, - [router], - ); - if (items.length === 0) return null; return ( @@ -80,7 +61,7 @@ export function TitleRow({ heading, icon, items }: TitleRowProps) { posterPath={item.posterPath} releaseDate={item.releaseDate} voteAverage={item.voteAverage} - onImport={() => handleImport(item.tmdbId, item.type)} + href={`/titles/tmdb-${item.tmdbId}-${item.type}`} /> ))} diff --git a/app/(pages)/titles/[id]/page.tsx b/app/(pages)/titles/[id]/page.tsx index 30e0e58..fc3ccd8 100644 --- a/app/(pages)/titles/[id]/page.tsx +++ b/app/(pages)/titles/[id]/page.tsx @@ -106,6 +106,8 @@ const staggerItem = { }, }; +const TMDB_ID_PATTERN = /^tmdb-(\d+)-(movie|tv)$/; + export default function TitleDetailPage() { const { id } = useParams<{ id: string }>(); const router = useRouter(); @@ -117,12 +119,56 @@ export default function TitleDetailPage() { const [openSeason, setOpenSeason] = useState(null); const [watchingEp, setWatchingEp] = useState(null); + // Parse TMDB ID format: tmdb-{id}-{type} + const tmdbMatch = TMDB_ID_PATTERN.exec(id); + const isTmdbFormat = !!tmdbMatch; + + const [resolvedId, setResolvedId] = useState( + isTmdbFormat ? null : id, + ); + const [resolveError, setResolveError] = useState(null); + + // Resolve TMDB ID to internal UUID + useEffect(() => { + if (!isTmdbFormat) return; + const tmdbId = Number(tmdbMatch[1]); + const type = tmdbMatch[2] as "movie" | "tv"; + let cancelled = false; + + fetch("/api/titles/resolve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tmdbId, type }), + }) + .then((res) => { + if (!res.ok) throw new Error("Failed to resolve title"); + return res.json(); + }) + .then((data) => { + if (cancelled) return; + if (data.id) { + setResolvedId(data.id); + router.replace(`/titles/${data.id}`, { scroll: false }); + } else { + setResolveError("Title could not be imported"); + } + }) + .catch(() => { + if (!cancelled) setResolveError("Failed to load title"); + }); + + return () => { + cancelled = true; + }; + }, [isTmdbFormat, tmdbMatch, router]); + const fetchTitle = useCallback(async () => { + if (!resolvedId) return; setLoading(true); try { const [titleRes, statusRes] = await Promise.all([ - fetch(`/api/titles/${id}`), - fetch(`/api/titles/${id}/status`), + fetch(`/api/titles/${resolvedId}`), + fetch(`/api/titles/${resolvedId}/status`), ]); const titleData = await titleRes.json(); let statusData = { status: null, rating: null, episodeWatches: [] }; @@ -138,11 +184,12 @@ export default function TitleDetailPage() { } finally { setLoading(false); } - }, [id]); + }, [resolvedId]); const fetchRecommendations = useCallback(async () => { + if (!resolvedId) return; try { - const res = await fetch(`/api/titles/${id}/recommendations`); + const res = await fetch(`/api/titles/${resolvedId}/recommendations`); if (res.ok) { const data = await res.json(); setRecommendations(data ?? []); @@ -150,7 +197,7 @@ export default function TitleDetailPage() { } catch { // silent } - }, [id]); + }, [resolvedId]); useEffect(() => { fetchTitle(); @@ -168,7 +215,7 @@ export default function TitleDetailPage() { // Optimistic update setTitle((t) => (t ? { ...t, userStatus: status } : t)); try { - const res = await fetch(`/api/titles/${id}/status`, { + const res = await fetch(`/api/titles/${resolvedId}/status`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status }), @@ -191,7 +238,7 @@ export default function TitleDetailPage() { toast.error("Failed to update status"); } }, - [id, title?.userStatus], + [resolvedId, title?.userStatus], ); const handleRating = useCallback( @@ -200,7 +247,7 @@ export default function TitleDetailPage() { // Optimistic update setTitle((t) => (t ? { ...t, userRating: ratingStars } : t)); try { - const res = await fetch(`/api/titles/${id}/rating`, { + const res = await fetch(`/api/titles/${resolvedId}/rating`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ratingStars }), @@ -216,13 +263,15 @@ export default function TitleDetailPage() { toast.error("Failed to update rating"); } }, - [id, title?.userRating], + [resolvedId, title?.userRating], ); const handleWatchMovie = useCallback(async () => { setTitle((t) => (t ? { ...t, userStatus: "completed" } : t)); try { - const res = await fetch(`/api/movies/${id}/watch`, { method: "POST" }); + const res = await fetch(`/api/movies/${resolvedId}/watch`, { + method: "POST", + }); if (!res.ok) throw new Error(); toast.success(`Marked "${title?.title}" as watched`); } catch { @@ -231,7 +280,7 @@ export default function TitleDetailPage() { ); toast.error("Failed to mark as watched"); } - }, [id, title?.title, title?.userStatus]); + }, [resolvedId, title?.title, title?.userStatus]); const handleWatchEpisode = useCallback( async ( @@ -353,7 +402,12 @@ export default function TitleDetailPage() { }); } - if (loading) { + if (resolveError) { + return ( +

{resolveError}

+ ); + } + if (!resolvedId || loading) { return ; } if (!title) { diff --git a/app/api/titles/[id]/route.ts b/app/api/titles/[id]/route.ts index 518b0ef..164aae0 100644 --- a/app/api/titles/[id]/route.ts +++ b/app/api/titles/[id]/route.ts @@ -7,7 +7,7 @@ import { parseColorPalette, } from "@/lib/services/colors"; import { refreshTvChildren } from "@/lib/services/metadata"; -import { getTvDetails } from "@/lib/tmdb/client"; +import { getMovieDetails, getTvDetails } from "@/lib/tmdb/client"; import { tmdbImageUrl } from "@/lib/tmdb/image"; export async function GET( @@ -44,6 +44,36 @@ export async function GET( } } + // If this is a shell movie title (created by recommendations with no full data), + // fetch the full details now. + if (title.type === "movie" && !title.lastFetchedAt) { + try { + const movie = await getMovieDetails(title.tmdbId); + await db + .update(titles) + .set({ + title: movie.title, + originalTitle: movie.original_title, + overview: movie.overview, + releaseDate: movie.release_date || null, + posterPath: movie.poster_path, + backdropPath: movie.backdrop_path, + popularity: movie.popularity, + voteAverage: movie.vote_average, + voteCount: movie.vote_count, + status: movie.status, + lastFetchedAt: new Date(), + }) + .where(eq(titles.id, id)) + .run(); + title = + (await db.select().from(titles).where(eq(titles.id, id)).get()) ?? + title; + } catch { + // Continue with whatever data we have + } + } + let titleSeasons: Array<{ id: string; seasonNumber: number; diff --git a/app/api/titles/resolve/route.ts b/app/api/titles/resolve/route.ts new file mode 100644 index 0000000..d7ac006 --- /dev/null +++ b/app/api/titles/resolve/route.ts @@ -0,0 +1,26 @@ +import { headers } from "next/headers"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth/server"; +import { importTitle } from "@/lib/services/metadata"; + +export async function POST(req: NextRequest) { + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const body = await req.json(); + const { tmdbId, type } = body; + + if (!tmdbId || !type || !["movie", "tv"].includes(type)) { + return NextResponse.json( + { error: "tmdbId and type (movie|tv) are required" }, + { status: 400 }, + ); + } + + const title = await importTitle(tmdbId, type, { awaitEnrichment: true }); + return NextResponse.json({ id: title?.id }); +} diff --git a/components/command-palette.tsx b/components/command-palette.tsx index ead667a..729a971 100644 --- a/components/command-palette.tsx +++ b/components/command-palette.tsx @@ -69,7 +69,6 @@ export function CommandPalette() { const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); - const [importing, setImporting] = useState(null); const [recentSearches, setRecentSearches] = useState([]); const debouncedQuery = useDebounce(query, 300); @@ -135,22 +134,9 @@ export function CommandPalette() { }, [debouncedQuery]); const handleSelect = useCallback( - async (result: SearchResult) => { - setImporting(result.tmdbId); - try { - const res = await fetch("/api/titles/import", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tmdbId: result.tmdbId, type: result.type }), - }); - const title = await res.json(); - if (title.id) { - setCommandPaletteOpen(false); - router.push(`/titles/${title.id}`); - } - } finally { - setImporting(null); - } + (result: SearchResult) => { + setCommandPaletteOpen(false); + router.push(`/titles/tmdb-${result.tmdbId}-${result.type}`); }, [router, setCommandPaletteOpen], ); @@ -205,7 +191,6 @@ export function CommandPalette() { handleSelect(r)} - disabled={importing === r.tmdbId} className="flex items-center gap-3 py-2" >
@@ -237,9 +222,6 @@ export function CommandPalette() { )}
- {importing === r.tmdbId && ( -
- )} ))} diff --git a/components/search-autocomplete.tsx b/components/search-autocomplete.tsx index 63ddfca..f24aa2e 100644 --- a/components/search-autocomplete.tsx +++ b/components/search-autocomplete.tsx @@ -34,7 +34,6 @@ export function SearchAutocomplete({ const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const [filter, setFilter] = useState<"all" | "movie" | "tv">("all"); - const [importing, setImporting] = useState(null); const debouncedQuery = useDebounce(query, 300); const inputRef = useRef(null); @@ -79,25 +78,9 @@ export function SearchAutocomplete({ }; }, [debouncedQuery, filter, onResults, onLoading]); - const handleImport = useCallback( - async (result: SearchResult) => { - setImporting(result.tmdbId); - try { - const res = await fetch("/api/titles/import", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tmdbId: result.tmdbId, type: result.type }), - }); - const title = await res.json(); - if (title.id) { - toast.success(`Added "${result.title}" to library`); - router.push(`/titles/${title.id}`); - } - } catch { - toast.error("Failed to import title"); - } finally { - setImporting(null); - } + const handleSelect = useCallback( + (result: SearchResult) => { + router.push(`/titles/tmdb-${result.tmdbId}-${result.type}`); }, [router], ); @@ -159,7 +142,7 @@ export function SearchAutocomplete({ handleImport(r)} + onSelect={() => handleSelect(r)} className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 text-sm data-[selected=true]:bg-accent" >
@@ -200,9 +183,6 @@ export function SearchAutocomplete({

)}
- {importing === r.tmdbId && ( -
- )} ))} diff --git a/lib/services/metadata.ts b/lib/services/metadata.ts index 06d7286..313dc84 100644 --- a/lib/services/metadata.ts +++ b/lib/services/metadata.ts @@ -21,7 +21,12 @@ import { imageCacheEnabled, } from "./image-cache"; -export async function importTitle(tmdbId: number, type: "movie" | "tv") { +export async function importTitle( + tmdbId: number, + type: "movie" | "tv", + options?: { awaitEnrichment?: boolean }, +) { + const awaitEnrichment = options?.awaitEnrichment ?? false; const existing = await db .select() .from(titles) @@ -55,8 +60,17 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") { .run(); } await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons); - refreshAvailability(existing.id).catch(() => {}); + if (awaitEnrichment) { + await refreshAvailability(existing.id).catch(() => {}); + } else { + refreshAvailability(existing.id).catch(() => {}); + } await refreshRecommendations(existing.id).catch(() => {}); + if (awaitEnrichment) { + await extractAndStoreColors(existing.id, show.poster_path).catch( + () => {}, + ); + } if (imageCacheEnabled()) { cacheImagesForTitle(existing.id).catch(() => {}); cacheEpisodeStills(existing.id).catch(() => {}); @@ -90,10 +104,15 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") { }) .returning() .get(); - // Fire-and-forget: fetch availability, recommendations, colors & image cache - refreshAvailability(row.id).catch(() => {}); - await refreshRecommendations(row.id).catch(() => {}); - extractAndStoreColors(row.id, movie.poster_path).catch(() => {}); + if (awaitEnrichment) { + await refreshAvailability(row.id).catch(() => {}); + await refreshRecommendations(row.id).catch(() => {}); + await extractAndStoreColors(row.id, movie.poster_path).catch(() => {}); + } else { + refreshAvailability(row.id).catch(() => {}); + await refreshRecommendations(row.id).catch(() => {}); + extractAndStoreColors(row.id, movie.poster_path).catch(() => {}); + } if (imageCacheEnabled()) cacheImagesForTitle(row.id).catch(() => {}); return row; } @@ -120,10 +139,15 @@ export async function importTitle(tmdbId: number, type: "movie" | "tv") { .get(); await refreshTvChildren(row.id, tmdbId, show.number_of_seasons); - // Fire-and-forget: fetch availability, recommendations, colors & image cache - refreshAvailability(row.id).catch(() => {}); - await refreshRecommendations(row.id).catch(() => {}); - extractAndStoreColors(row.id, show.poster_path).catch(() => {}); + if (awaitEnrichment) { + await refreshAvailability(row.id).catch(() => {}); + await refreshRecommendations(row.id).catch(() => {}); + await extractAndStoreColors(row.id, show.poster_path).catch(() => {}); + } else { + refreshAvailability(row.id).catch(() => {}); + await refreshRecommendations(row.id).catch(() => {}); + extractAndStoreColors(row.id, show.poster_path).catch(() => {}); + } if (imageCacheEnabled()) { cacheImagesForTitle(row.id).catch(() => {}); cacheEpisodeStills(row.id).catch(() => {});