diff --git a/app/(pages)/titles/[id]/_components/title-availability.tsx b/app/(pages)/titles/[id]/_components/title-availability.tsx index e8dfb2b..d3b9d24 100644 --- a/app/(pages)/titles/[id]/_components/title-availability.tsx +++ b/app/(pages)/titles/[id]/_components/title-availability.tsx @@ -19,13 +19,25 @@ const offerLabels: Record = { function ProviderBadge({ name, logoPath, + watchUrl, }: { name: string; logoPath: string | null; + watchUrl: string | null; }) { return ( - + + ), + } + : {})} + className={`flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border border-border/30 bg-card motion-safe:transition-transform motion-safe:hover:scale-105${watchUrl ? "" : " cursor-default"}`} + > {logoPath ? ( - {name} + {watchUrl ? `Watch on ${name}` : name} ); @@ -77,6 +89,7 @@ export function TitleAvailability({ key={offer.providerId} name={offer.providerName} logoPath={offer.logoPath} + watchUrl={offer.watchUrl} /> ))} diff --git a/lib/providers.ts b/lib/providers.ts new file mode 100644 index 0000000..6e81564 --- /dev/null +++ b/lib/providers.ts @@ -0,0 +1,120 @@ +/** + * Provider registry mapping TMDB provider IDs to search URL templates. + * + * To add a new provider: + * 1. Find the TMDB provider_id (visible in availability data or TMDB API) + * 2. Add an entry below with the service's search URL using {title} placeholder + */ + +interface ProviderConfig { + name: string; + searchUrl: string; +} + +// TMDB provider_id → search URL config +const providers: Record = { + // Netflix + 8: { name: "Netflix", searchUrl: "https://www.netflix.com/search?q={title}" }, + 1796: { + name: "Netflix basic with Ads", + searchUrl: "https://www.netflix.com/search?q={title}", + }, + + // Amazon + 9: { + name: "Amazon Prime Video", + searchUrl: "https://www.amazon.com/s?i=instant-video&k={title}", + }, + 10: { + name: "Amazon Video", + searchUrl: "https://www.amazon.com/s?i=instant-video&k={title}", + }, + 119: { + name: "Amazon Prime Video", + searchUrl: "https://www.amazon.com/s?i=instant-video&k={title}", + }, + + // Disney+ + 337: { + name: "Disney+", + searchUrl: "https://www.disneyplus.com/search/{title}", + }, + + // Apple + 2: { + name: "Apple iTunes", + searchUrl: "https://tv.apple.com/search?term={title}", + }, + 350: { + name: "Apple TV+", + searchUrl: "https://tv.apple.com/search?term={title}", + }, + + // Hulu + 15: { name: "Hulu", searchUrl: "https://www.hulu.com/search?q={title}" }, + + // Max (HBO) + 384: { name: "HBO Max", searchUrl: "https://play.max.com/search?q={title}" }, + 1899: { name: "Max", searchUrl: "https://play.max.com/search?q={title}" }, + + // Paramount+ + 531: { + name: "Paramount+", + searchUrl: "https://www.paramountplus.com/search/?q={title}", + }, + + // Peacock + 386: { + name: "Peacock", + searchUrl: "https://www.peacocktv.com/search?q={title}", + }, + + // Google Play + 3: { + name: "Google Play Movies", + searchUrl: "https://play.google.com/store/search?q={title}&c=movies", + }, + + // YouTube + 192: { + name: "YouTube", + searchUrl: "https://www.youtube.com/results?search_query={title}", + }, + + // Crunchyroll + 283: { + name: "Crunchyroll", + searchUrl: "https://www.crunchyroll.com/search?q={title}", + }, + + // Free / ad-supported + 73: { name: "Tubi", searchUrl: "https://tubitv.com/search/{title}" }, + 300: { + name: "Pluto TV", + searchUrl: "https://pluto.tv/search/details?q={title}", + }, + + // Other + 257: { name: "fuboTV", searchUrl: "https://www.fubo.tv/search/{title}" }, + 43: { + name: "Starz", + searchUrl: "https://www.starz.com/search?query={title}", + }, + 37: { + name: "Showtime", + searchUrl: "https://www.sho.com/search?q={title}", + }, +}; + +const providerRegistry: ReadonlyMap = new Map( + Object.entries(providers).map(([id, config]) => [Number(id), config]), +); + +export function generateProviderUrl( + providerId: number, + titleName: string, +): string | null { + const config = providerRegistry.get(providerId); + if (!config) return null; + return config.searchUrl.replace("{title}", encodeURIComponent(titleName)); +} diff --git a/lib/services/metadata.ts b/lib/services/metadata.ts index 50530d0..2716015 100644 --- a/lib/services/metadata.ts +++ b/lib/services/metadata.ts @@ -10,6 +10,7 @@ import { titles, } from "@/lib/db/schema"; import { createLogger } from "@/lib/logger"; +import { generateProviderUrl } from "@/lib/providers"; import { getMovieDetails, getRecommendations, @@ -715,7 +716,10 @@ async function ensureEnriched( return false; } -function readAvailability(titleId: string): AvailabilityOffer[] { +function readAvailability( + titleId: string, + titleName: string, +): AvailabilityOffer[] { return db .select() .from(availabilityOffers) @@ -726,6 +730,7 @@ function readAvailability(titleId: string): AvailabilityOffer[] { providerName: a.providerName, logoPath: tmdbImageUrl(a.logoPath, "w92"), offerType: a.offerType, + watchUrl: generateProviderUrl(a.providerId, titleName), })); } @@ -777,7 +782,7 @@ export async function getTitleWithChildren(id: string): Promise<{ const titleSeasons = needsTvHydration ? [] : (existingSeasons ?? []); // Read enrichment data, then backfill anything missing - let availability = readAvailability(title.id); + let availability = readAvailability(title.id, title.title); let cast = getCastForTitle(id); if (title.lastFetchedAt) { @@ -788,7 +793,8 @@ export async function getTitleWithChildren(id: string): Promise<{ if (enriched) { // Re-read only what was missing if (cast.length === 0) cast = getCastForTitle(id); - if (availability.length === 0) availability = readAvailability(title.id); + if (availability.length === 0) + availability = readAvailability(title.id, title.title); title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title; } } diff --git a/lib/types/title.ts b/lib/types/title.ts index 06fa5b7..01487a7 100644 --- a/lib/types/title.ts +++ b/lib/types/title.ts @@ -20,6 +20,7 @@ export interface AvailabilityOffer { providerName: string; logoPath: string | null; offerType: string; + watchUrl: string | null; } export interface RecommendedTitle {