Add clickable watch links to streaming provider badges

Build a provider registry mapping TMDB provider IDs to search URL
templates. `generateProviderUrl()` resolves a URL for a given provider
and title name using URL-encoded search queries. `readAvailability()`
now accepts the title name and attaches `watchUrl` to each offer.

Provider badges with a resolved URL render as `<a>` links opening in a
new tab; tooltip copy switches from the provider name to "Watch on
{name}". Badges without a known URL remain non-interactive as before.
This commit is contained in:
2026-03-05 20:35:59 -05:00
parent 926ddfef34
commit cd4b2908d8
4 changed files with 145 additions and 5 deletions
@@ -19,13 +19,25 @@ const offerLabels: Record<string, string> = {
function ProviderBadge({ function ProviderBadge({
name, name,
logoPath, logoPath,
watchUrl,
}: { }: {
name: string; name: string;
logoPath: string | null; logoPath: string | null;
watchUrl: string | null;
}) { }) {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger 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"> <TooltipTrigger
{...(watchUrl
? {
render: (
// biome-ignore lint/a11y/useAnchorContent: content is provided conditionally below
<a href={watchUrl} target="_blank" rel="noopener noreferrer" />
),
}
: {})}
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 ? ( {logoPath ? (
<Image <Image
src={logoPath} src={logoPath}
@@ -41,7 +53,7 @@ function ProviderBadge({
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="bg-popover px-2 py-1 text-[10px] font-medium text-popover-foreground shadow-md [&>:last-child]:bg-popover [&>:last-child]:fill-popover"> <TooltipContent className="bg-popover px-2 py-1 text-[10px] font-medium text-popover-foreground shadow-md [&>:last-child]:bg-popover [&>:last-child]:fill-popover">
{name} {watchUrl ? `Watch on ${name}` : name}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );
@@ -77,6 +89,7 @@ export function TitleAvailability({
key={offer.providerId} key={offer.providerId}
name={offer.providerName} name={offer.providerName}
logoPath={offer.logoPath} logoPath={offer.logoPath}
watchUrl={offer.watchUrl}
/> />
))} ))}
</div> </div>
+120
View File
@@ -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<number, ProviderConfig> = {
// 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<number, ProviderConfig> = 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));
}
+9 -3
View File
@@ -10,6 +10,7 @@ import {
titles, titles,
} from "@/lib/db/schema"; } from "@/lib/db/schema";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { generateProviderUrl } from "@/lib/providers";
import { import {
getMovieDetails, getMovieDetails,
getRecommendations, getRecommendations,
@@ -715,7 +716,10 @@ async function ensureEnriched(
return false; return false;
} }
function readAvailability(titleId: string): AvailabilityOffer[] { function readAvailability(
titleId: string,
titleName: string,
): AvailabilityOffer[] {
return db return db
.select() .select()
.from(availabilityOffers) .from(availabilityOffers)
@@ -726,6 +730,7 @@ function readAvailability(titleId: string): AvailabilityOffer[] {
providerName: a.providerName, providerName: a.providerName,
logoPath: tmdbImageUrl(a.logoPath, "w92"), logoPath: tmdbImageUrl(a.logoPath, "w92"),
offerType: a.offerType, offerType: a.offerType,
watchUrl: generateProviderUrl(a.providerId, titleName),
})); }));
} }
@@ -777,7 +782,7 @@ export async function getTitleWithChildren(id: string): Promise<{
const titleSeasons = needsTvHydration ? [] : (existingSeasons ?? []); const titleSeasons = needsTvHydration ? [] : (existingSeasons ?? []);
// Read enrichment data, then backfill anything missing // Read enrichment data, then backfill anything missing
let availability = readAvailability(title.id); let availability = readAvailability(title.id, title.title);
let cast = getCastForTitle(id); let cast = getCastForTitle(id);
if (title.lastFetchedAt) { if (title.lastFetchedAt) {
@@ -788,7 +793,8 @@ export async function getTitleWithChildren(id: string): Promise<{
if (enriched) { if (enriched) {
// Re-read only what was missing // Re-read only what was missing
if (cast.length === 0) cast = getCastForTitle(id); 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; title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
} }
} }
+1
View File
@@ -20,6 +20,7 @@ export interface AvailabilityOffer {
providerName: string; providerName: string;
logoPath: string | null; logoPath: string | null;
offerType: string; offerType: string;
watchUrl: string | null;
} }
export interface RecommendedTitle { export interface RecommendedTitle {