mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -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({
|
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>
|
||||||
|
|||||||
@@ -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,
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user