mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 00:25:38 -04:00
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:
@@ -19,13 +19,25 @@ const offerLabels: Record<string, string> = {
|
||||
function ProviderBadge({
|
||||
name,
|
||||
logoPath,
|
||||
watchUrl,
|
||||
}: {
|
||||
name: string;
|
||||
logoPath: string | null;
|
||||
watchUrl: string | null;
|
||||
}) {
|
||||
return (
|
||||
<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 ? (
|
||||
<Image
|
||||
src={logoPath}
|
||||
@@ -41,7 +53,7 @@ function ProviderBadge({
|
||||
)}
|
||||
</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">
|
||||
{name}
|
||||
{watchUrl ? `Watch on ${name}` : name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -77,6 +89,7 @@ export function TitleAvailability({
|
||||
key={offer.providerId}
|
||||
name={offer.providerName}
|
||||
logoPath={offer.logoPath}
|
||||
watchUrl={offer.watchUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface AvailabilityOffer {
|
||||
providerName: string;
|
||||
logoPath: string | null;
|
||||
offerType: string;
|
||||
watchUrl: string | null;
|
||||
}
|
||||
|
||||
export interface RecommendedTitle {
|
||||
|
||||
Reference in New Issue
Block a user