feat(browse): justwatch discovery pages with service-matched offers

This commit is contained in:
imSp4rky
2026-08-13 17:55:35 -06:00
parent 2f5e974729
commit 9184d97dfc
26 changed files with 1278 additions and 16 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "unshackle-ui",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "unshackle-ui",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-router": "^1.170.23",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "unshackle-ui",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite dev",
+9
View File
@@ -51,4 +51,13 @@
html.incognito .redact {
-webkit-text-security: disc;
}
/* Images can't be value-swapped either, so blur them. Posters stay recognizable at the
icon strength, so they need more. */
html.incognito .redact-img {
filter: blur(8px);
}
html.incognito .redact-poster {
filter: blur(12px);
}
}
+2 -1
View File
@@ -30,7 +30,8 @@ export interface Service {
tag: string;
aliases: string[];
geofence: string[];
title_regex: string[];
/** A bare string when the service defines a single pattern instead of a list. */
title_regex: string[] | string;
url: string;
help: string;
cli_params: CliParam[];
+24
View File
@@ -0,0 +1,24 @@
// Browser client for the UI's own JustWatch proxy routes (/api/browse/*). Same-origin
// like $lib/tracking/client, whose request helper (and error type) it reuses.
import { request } from '$lib/tracking/client';
import type { BrowseSearchResponse, PopularResponse, TitleDetailResponse } from './types';
export const browse = {
popular: (country: string) =>
request<PopularResponse>(`/api/browse/popular?country=${encodeURIComponent(country)}`),
popularList: (type: 'movies' | 'tv', country: string) =>
request<BrowseSearchResponse>(
`/api/browse/popular/${type}?country=${encodeURIComponent(country)}`
),
search: (q: string, country: string) =>
request<BrowseSearchResponse>(
`/api/browse/search?q=${encodeURIComponent(q)}&country=${encodeURIComponent(country)}`
),
title: (id: string, country: string) =>
request<TitleDetailResponse>(
`/api/browse/title/${encodeURIComponent(id)}?country=${encodeURIComponent(country)}`
)
};
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest';
import type { Service } from '$lib/api/types';
import { cleanOfferUrl, matchOffer, toJsRegex } from './match';
const svc = (tag: string, title_regex: string[] | string): Service => ({
tag,
aliases: [],
geofence: [],
title_regex,
url: '',
help: '',
cli_params: []
});
describe('toJsRegex', () => {
it('translates (?P<name>) groups', () => {
const re = toJsRegex(String.raw`(?P<id>umc\.cmc\.[a-z0-9]+)`);
expect(re?.exec('https://tv.apple.com/us/show/x/umc.cmc.1zzly0vah')?.groups?.id).toBe(
'umc.cmc.1zzly0vah'
);
});
it('hoists inline (?i) flags', () => {
expect(toJsRegex('(?i)netflix')?.test('NETFLIX.com')).toBe(true);
});
it('returns null on invalid patterns instead of throwing', () => {
expect(toJsRegex('(?P<id>[a-z')).toBeNull();
});
it('squeezes Python verbose-mode patterns (whitespace + # comments)', () => {
const verbose =
'^(?:https?://www\\.netflix\\.com/(?:[a-z]{2}/)?title/)? # optional url\n(?P<id>\\d+)$';
const re = toJsRegex(verbose);
expect(re?.exec('https://www.netflix.com/title/81234567')?.groups?.id).toBe('81234567');
});
});
describe('cleanOfferUrl', () => {
it('unwraps bn5x.net affiliate links', () => {
const wrapped =
'https://www.bn5x.net/c/1234/5678?u=' +
encodeURIComponent('https://www.disneyplus.com/movies/x/abc123');
expect(cleanOfferUrl(wrapped)).toBe('https://www.disneyplus.com/movies/x/abc123');
});
it('strips tracking query strings from known hosts', () => {
expect(cleanOfferUrl('https://tv.apple.com/us/show/x/umc.cmc.abc?at=1000&ct=jw')).toBe(
'https://tv.apple.com/us/show/x/umc.cmc.abc'
);
expect(cleanOfferUrl('https://play.max.com/show/uuid-1?irclickid=x')).toBe(
'https://play.max.com/show/uuid-1'
);
});
it('rewrites Amazon gti links (any host) to the canonical amazon.com form', () => {
expect(cleanOfferUrl('https://watch.amazon.com/detail?gti=amzn1.dv.gti.92c7e5ed-9cf4')).toBe(
'https://www.amazon.com/gp/video/detail/amzn1.dv.gti.92c7e5ed-9cf4/'
);
// CA/UK arrive on app.primevideo.com, which the AMZN title_regex doesn't know.
expect(cleanOfferUrl('https://app.primevideo.com/detail?gti=amzn1.dv.gti.414eb1af')).toBe(
'https://www.amazon.com/gp/video/detail/amzn1.dv.gti.414eb1af/'
);
});
it('leaves other hosts and non-URLs alone', () => {
expect(cleanOfferUrl('https://www.netflix.com/title/81234567?src=jw')).toBe(
'https://www.netflix.com/title/81234567?src=jw'
);
expect(cleanOfferUrl('not a url')).toBe('not a url');
});
});
describe('matchOffer', () => {
const services = [
svc('NF', [String.raw`netflix\.com/title/(?P<id>\d+)`]),
svc('ATV', [
String.raw`^(?:https?://tv\.apple\.com(?:/[a-z]{2})?/(?:movie|show|episode)/[a-z0-9-]+/)?(?P<id>umc\.cmc\.[a-z0-9]+)`
]),
svc('BROKEN', ['(?P<id>[unclosed'])
];
it('finds the service and extracts the id', () => {
expect(matchOffer('https://tv.apple.com/us/show/x/umc.cmc.1zzly0vah', services)).toEqual({
service: 'ATV',
id: 'umc.cmc.1zzly0vah'
});
expect(matchOffer('https://www.netflix.com/title/81234567', services)).toEqual({
service: 'NF',
id: '81234567'
});
});
it('returns null when nothing matches, surviving broken regexes', () => {
expect(matchOffer('https://www.hulu.com/series/abc', services)).toBeNull();
});
it('accepts title_regex serialized as a bare string, not iterating it as characters', () => {
const amzn = [
svc(
'AMZN',
String.raw`^(?:https?://(?:www\.)?(?:amazon\.(?:com|co\.uk|de|co\.jp)|primevideo\.com)(?:/.+)?/)?(?P<id>[A-Z0-9]{10,}|amzn1\.dv\.gti\.[a-f0-9-]+)`
)
];
expect(
matchOffer(cleanOfferUrl('https://watch.amazon.com/detail?gti=amzn1.dv.gti.92c7e5ed'), amzn)
).toEqual({ service: 'AMZN', id: 'amzn1.dv.gti.92c7e5ed' });
});
it('prefers the match that claimed the hostname over a mid-URL path fragment', () => {
// NOW TV (Italy) shares Peacock's platform, so its generic /asset(...) pattern also
// matches peacocktv.com URLs, but only from mid-URL. PCOK consumes the host.
const shared = [
svc('NOWTVIT', [String.raw`/asset(/[^?#]+)`]),
svc('PCOK', [
String.raw`(?:https?://(?:www\.)?peacocktv\.com/watch/asset/|/?)(?P<id>movies/[a-z0-9/./-]+/[a-f0-9-]+)`
])
];
expect(
matchOffer('https://www.peacocktv.com/watch/asset/movies/obsession/210b50e4-5ccd', shared)
).toEqual({ service: 'PCOK', id: 'movies/obsession/210b50e4-5ccd' });
});
it('rejects catch-all patterns whose optional URL prefix did not participate', () => {
// Real shape from unshackle: bare-id fallback means the pattern matches ANY string.
const catchAll = [
svc('PCOK', [String.raw`^(?:https?://www\.peacocktv\.com/watch/asset/)?(?P<id>.+)$`])
];
expect(
matchOffer('https://watch.amazon.com/detail?gti=amzn1.dv.gti.92c7e5ed', catchAll)
).toBeNull();
expect(
matchOffer(
'https://www.peacocktv.com/watch/asset/tv/the-office/4902514835143843112',
catchAll
)
).toEqual({ service: 'PCOK', id: 'tv/the-office/4902514835143843112' });
});
});
+126
View File
@@ -0,0 +1,126 @@
// Matching JustWatch offers to configured unshackle services.
//
// Each service's title_regex (from /api/services) is a Python regex that pulls a title id
// out of a pasted URL. Run against an offer's deep link, a match names the service and
// gives the id for /title/$service/$id. Dependency-free on purpose: imported by both the
// browser and src/server/justwatch.ts.
import type { Service } from '$lib/api/types';
/** Verbose-mode (re.VERBOSE) patterns are multi-line with # comments; JS has no x flag. */
function stripVerbose(p: string): string {
let out = '';
let inClass = false;
for (let i = 0; i < p.length; i++) {
const ch = p[i];
if (ch === '\\' && i + 1 < p.length) {
out += ch + p[i + 1];
i++;
continue;
}
if (ch === '[') inClass = true;
else if (ch === ']') inClass = false;
if (!inClass) {
if (/\s/.test(ch)) continue;
if (ch === '#') {
while (i < p.length && p[i] !== '\n') i++;
continue;
}
}
out += ch;
}
return out;
}
/**
* Translate a Python regex to a JS RegExp, or null when it uses constructs JS rejects.
* Null is fine: the offer just renders unmatched instead of the page crashing.
*/
export function toJsRegex(pattern: string): RegExp | null {
let flags = '';
let p = pattern
// Inline flags like (?i) are a syntax error in JS; hoist the ones that exist there.
.replace(/\(\?([aiLmsux]+)\)/g, (_, fs: string) => {
for (const f of ['i', 'm', 's'] as const) if (fs.includes(f)) flags += f;
return '';
})
.replaceAll('(?P<', '(?<')
.replace(/\(\?P=(\w+)\)/g, '\\k<$1>');
// A multi-line pattern is a verbose-mode one. It may even compile in JS, with the
// whitespace and # comments as literals matching nothing, so squeeze it up front.
if (p.includes('\n')) p = stripVerbose(p);
try {
return new RegExp(p, flags);
} catch {
try {
return new RegExp(stripVerbose(p), flags);
} catch {
return null;
}
}
}
/**
* Best service whose title_regex matches the URL, with the extracted title id.
*
* Several services share a platform (Peacock and NOW TV both use /watch/asset/ paths), so
* more than one regex can match. The one starting earliest, longest on a tie, claimed the
* hostname rather than a path fragment, so it names the right service.
*/
export function matchOffer(
url: string,
services: Service[]
): { service: string; id: string } | null {
let best: { service: string; id: string; index: number; len: number } | null = null;
for (const svc of services) {
// A single-pattern service serializes title_regex as a bare string; iterating
// that as-is would yield one character at a time.
const patterns =
typeof svc.title_regex === 'string' ? [svc.title_regex] : (svc.title_regex ?? []);
for (const pattern of patterns) {
const m = toJsRegex(pattern)?.exec(url);
if (!m) continue;
const id = m.groups?.id ?? m[1];
// Most patterns make their URL prefix optional so bare ids also match, which
// turns some of them into catch-alls against a full URL. Only trust a match
// where the prefix actually consumed something and the id isn't the URL itself.
if (!id || id.includes('://') || m[0].length <= id.length) continue;
if (!best || m.index < best.index || (m.index === best.index && m[0].length > best.len))
best = { service: svc.tag, id, index: m.index, len: m[0].length };
}
}
return best && { service: best.service, id: best.id };
}
/**
* Strip the affiliate/tracking noise JustWatch wraps deep links in, so title_regex sees
* the URL a user would paste. Unknown hosts pass through untouched.
*/
export function cleanOfferUrl(raw: string): string {
let url: URL;
try {
url = new URL(raw);
} catch {
return raw;
}
// Disney+ arrives as a bn5x.net affiliate redirect with the real link in `u`.
if (url.hostname.endsWith('bn5x.net')) {
const u = url.searchParams.get('u');
if (u) {
try {
url = new URL(u);
} catch {
return raw;
}
}
}
// A gti param carries the title on hosts the AMZN title_regex doesn't know
// (watch.amazon.com, app.primevideo.com, ...). The format is Amazon-only, so any
// link with one can be rewritten to the canonical form the regex does know.
const gti = url.searchParams.get('gti');
if (gti?.startsWith('amzn1.dv.gti.')) return `https://www.amazon.com/gp/video/detail/${gti}/`;
// These carry only tracking in the query string; the title id lives in the path.
const stripQuery = ['tv.apple.com', 'play.max.com', 'watch.plex.tv', 'therokuchannel.roku.com'];
if (stripQuery.some((h) => url.hostname === h || url.hostname.endsWith(`.${h}`)))
return url.origin + url.pathname;
return url.toString();
}
+43
View File
@@ -0,0 +1,43 @@
// Wire types for the UI's own JustWatch proxy routes (/api/browse/*), shared by
// src/server/justwatch.ts and the browser client, like tracking/types.
export interface BrowseTitle {
/** JustWatch GraphQL node id, the param of /browse/$id. */
id: string;
objectType: 'MOVIE' | 'SHOW';
title: string;
year: number | null;
/** Absolute URL (images.justwatch.com), or null when JustWatch has no artwork. */
posterUrl: string | null;
genres: string[];
synopsis: string | null;
runtime: number | null;
backdropUrl: string | null;
}
export interface BrowseOffer {
/** Cleaned provider deep link (affiliate wrappers unwrapped, tracking params stripped). */
url: string;
packageId: number;
packageName: string;
packageTechnicalName: string;
iconUrl: string | null;
/** FLATRATE / FREE / ADS / RENT / BUY …, deduped per provider, best-first. */
monetization: string[];
}
export interface PopularResponse {
movies: BrowseTitle[];
shows: BrowseTitle[];
}
export interface BrowseSearchResponse {
results: BrowseTitle[];
}
export interface TitleDetailResponse {
title: BrowseTitle;
offers: BrowseOffer[];
/** Countries the title has a JustWatch page in, sorted; [] when unknown. */
countries: string[];
}
+2
View File
@@ -11,6 +11,8 @@ export const PATHS = {
check: '<path d="M20 6 9 17l-5-5"/>',
film: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 3v18"/><path d="M3 7.5h4"/><path d="M3 12h18"/><path d="M3 16.5h4"/><path d="M17 3v18"/><path d="M17 7.5h4"/><path d="M17 16.5h4"/>',
chevron: '<path d="m9 18 6-6-6-6"/>',
compass:
'<circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>',
alert:
'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
loader:
+45
View File
@@ -0,0 +1,45 @@
import { Link } from '@tanstack/react-router';
import type { BrowseTitle } from '$lib/browse/types';
import { useMask } from '$lib/stores/incognito';
import Badge from './Badge';
import Icon from './Icon';
/** Grid of JustWatch posters linking into /browse/$id. */
export default function PosterGrid({
titles,
showType = false
}: {
titles: BrowseTitle[];
showType?: boolean;
}) {
const mask = useMask();
return (
<div className="mt-3 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-5">
{titles.map((t) => (
<Link key={t.id} to="/browse/$id" params={{ id: t.id }} className="group min-w-0">
{t.posterUrl ? (
<img
src={t.posterUrl}
alt={mask.title(t.title)}
loading="lazy"
className="redact-poster aspect-2/3 w-full rounded-lg border border-neutral-200 object-cover transition-opacity group-hover:opacity-85 dark:border-neutral-800"
/>
) : (
<div className="flex aspect-2/3 w-full items-center justify-center rounded-lg border border-neutral-200 bg-neutral-100 text-neutral-300 dark:border-neutral-800 dark:bg-neutral-800 dark:text-neutral-600">
<Icon name="film" size={28} />
</div>
)}
<div className="mt-1.5 flex items-center gap-1.5">
<span className="truncate text-sm font-medium text-neutral-900 group-hover:text-accent-600 dark:text-neutral-100 dark:group-hover:text-accent-400">
{mask.title(t.title)}
</span>
{showType && <Badge>{t.objectType === 'MOVIE' ? 'Movie' : 'Show'}</Badge>}
</div>
{t.year && (
<p className="text-xs text-neutral-500 dark:text-neutral-400">{mask.year(t.year)}</p>
)}
</Link>
))}
</div>
);
}
+8 -1
View File
@@ -10,6 +10,7 @@ import ThemeToggle from './ThemeToggle';
// typed `to` and every route in the sidebar stops being checked.
const nav = [
{ href: '/', label: 'Browse', icon: 'search' },
{ href: '/discover', label: 'Discover', icon: 'compass' },
{ href: '/downloads', label: 'Downloads', icon: 'download' },
{ href: '/tracking', label: 'Tracking', icon: 'bell' },
{ href: '/history', label: 'History', icon: 'history' },
@@ -24,8 +25,14 @@ export default function Sidebar() {
const status = useQuery(statusQuery);
const unseen = status.data?.unseen_total ?? 0;
// JustWatch detail and popular pages are reached from Browse, so they highlight it.
const active = (href: string) =>
href === '/' ? path === '/' || path.startsWith('/title') : path.startsWith(href);
href === '/'
? path === '/' ||
path.startsWith('/title') ||
path.startsWith('/browse') ||
path.startsWith('/popular')
: path.startsWith(href);
return (
<aside className="flex w-60 shrink-0 flex-col border-r border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
+4 -1
View File
@@ -3,13 +3,16 @@ import { browser, effect, useStore, writable } from '$lib/store';
export interface Settings {
apiUrl: string;
apiKey: string;
/** 2-letter country for JustWatch availability on the browse pages. */
country: string;
}
const KEY = 'unshackle.settings';
const defaults: Settings = {
apiUrl: import.meta.env.PUBLIC_UNSHACKLE_API_URL || 'http://localhost:8786',
apiKey: import.meta.env.PUBLIC_UNSHACKLE_API_KEY || ''
apiKey: import.meta.env.PUBLIC_UNSHACKLE_API_KEY || '',
country: 'US'
};
function load(): Settings {
+33
View File
@@ -1,6 +1,7 @@
import { queryOptions } from '@tanstack/react-query';
import { browser } from './store';
import { api } from './api/client';
import { browse } from './browse/client';
import { tracking } from './tracking/client';
// Both are read from more than one page, and neither changes during a session, so the
@@ -41,6 +42,38 @@ export const trackingSettingsQuery = queryOptions({
queryFn: () => tracking.settings()
});
// JustWatch data, proxied by this app's server, which holds the real TTL cache; these
// staleTimes only keep page switches from refetching.
export const popularQuery = (country: string) =>
queryOptions({
queryKey: ['browse', 'popular', country],
queryFn: () => browse.popular(country),
enabled: browser,
staleTime: 30 * 60_000
});
export const popularListQuery = (type: 'movies' | 'tv', country: string) =>
queryOptions({
queryKey: ['browse', 'popular', type, country],
queryFn: () => browse.popularList(type, country),
enabled: browser,
staleTime: 30 * 60_000
});
export const browseSearchQuery = (q: string, country: string) =>
queryOptions({
queryKey: ['browse', 'search', country, q],
queryFn: () => browse.search(q, country),
staleTime: 10 * 60_000
});
export const browseTitleQuery = (id: string, country: string) =>
queryOptions({
queryKey: ['browse', 'title', country, id],
queryFn: () => browse.title(id, country),
staleTime: 30 * 60_000
});
// Prefixed with the list's key on purpose: invalidating ['tracking'] refreshes both.
export const trackQuery = (id: string) =>
queryOptions({
+4 -1
View File
@@ -64,7 +64,10 @@ export function maskers(on: boolean) {
id: (s: Maskable) => (on && s ? `id-${hash(s).toString(36)}` : (s ?? '')),
profile: (s: Maskable) => (on && s ? pick(FAKE_PROFILES, s) : (s ?? '')),
// Arbitrary free text (descriptions, error output) can't be faked convincingly, so hide it.
text: (s: Maskable) => (on && s ? '(hidden by incognito)' : (s ?? ''))
text: (s: Maskable) => (on && s ? '(hidden by incognito)' : (s ?? '')),
// Numbers identify a title almost as well as its name; nudge them deterministically.
year: (n: number | null | undefined) => (on && n != null ? 1970 + (hash(`y${n}`) % 55) : n),
minutes: (n: number | null | undefined) => (on && n != null ? 45 + (hash(`m${n}`) % 135) : n)
};
}
+2 -1
View File
@@ -29,7 +29,8 @@ export function trackingErrorMessage(e: unknown): string {
return e instanceof TrackingError ? e.message : String(e);
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
// Exported for $lib/browse/client, which talks to this app's server the same way.
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response;
try {
res = await fetch(path, {
+22
View File
@@ -0,0 +1,22 @@
import { createFileRoute } from '@tanstack/react-router';
import { countryParam, fail } from '../server/http';
import { jwFail, popularList } from '../server/justwatch';
export const Route = createFileRoute('/api/browse/popular/$type')({
server: {
handlers: {
GET: async ({ params, request }) => {
const country = countryParam(request);
if (!country) return fail(400, 'country must be a 2-letter code');
const objectType =
params.type === 'movies' ? 'MOVIE' : params.type === 'tv' ? 'SHOW' : null;
if (!objectType) return fail(404, 'type must be movies or tv');
try {
return Response.json(await popularList(country, objectType));
} catch (e) {
return jwFail(e);
}
}
}
}
});
+19
View File
@@ -0,0 +1,19 @@
import { createFileRoute } from '@tanstack/react-router';
import { countryParam, fail } from '../server/http';
import { getPopular, jwFail } from '../server/justwatch';
export const Route = createFileRoute('/api/browse/popular')({
server: {
handlers: {
GET: async ({ request }) => {
const country = countryParam(request);
if (!country) return fail(400, 'country must be a 2-letter code');
try {
return Response.json(await getPopular(country));
} catch (e) {
return jwFail(e);
}
}
}
}
});
+21
View File
@@ -0,0 +1,21 @@
import { createFileRoute } from '@tanstack/react-router';
import { countryParam, fail, str } from '../server/http';
import { jwFail, searchTitles } from '../server/justwatch';
export const Route = createFileRoute('/api/browse/search')({
server: {
handlers: {
GET: async ({ request }) => {
const country = countryParam(request);
if (!country) return fail(400, 'country must be a 2-letter code');
const q = str(new URL(request.url).searchParams.get('q'));
if (!q) return fail(400, 'q is required');
try {
return Response.json(await searchTitles(q, country));
} catch (e) {
return jwFail(e);
}
}
}
}
});
+19
View File
@@ -0,0 +1,19 @@
import { createFileRoute } from '@tanstack/react-router';
import { countryParam, fail } from '../server/http';
import { getTitle, jwFail } from '../server/justwatch';
export const Route = createFileRoute('/api/browse/title/$id')({
server: {
handlers: {
GET: async ({ params, request }) => {
const country = countryParam(request);
if (!country) return fail(400, 'country must be a 2-letter code');
try {
return Response.json(await getTitle(params.id, country));
} catch (e) {
return jwFail(e);
}
}
}
}
});
+231
View File
@@ -0,0 +1,231 @@
import { useQuery } from '@tanstack/react-query';
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
import { matchOffer } from '$lib/browse/match';
import Badge from '$lib/components/Badge';
import Card from '$lib/components/Card';
import Icon from '$lib/components/Icon';
import { useSettings } from '$lib/config';
import { browseTitleQuery, servicesQuery } from '$lib/queries';
import { useIncognito, useMask } from '$lib/stores/incognito';
import { trackingErrorMessage } from '$lib/tracking/client';
// ssr: false because servicesQuery goes through the browser-only API client, same as
// the title page.
export const Route = createFileRoute('/browse/$id')({
ssr: false,
// Optional per-page region override; without it the Settings country applies.
validateSearch: (s: Record<string, unknown>): { country?: string } =>
typeof s.country === 'string' && /^[A-Za-z]{2}$/.test(s.country)
? { country: s.country.toUpperCase() }
: {},
component: BrowseTitlePage
});
function BrowseTitlePage() {
const mask = useMask();
const incognito = useIncognito();
const { id } = Route.useParams();
const search = Route.useSearch();
const settings = useSettings();
const country = search.country ?? settings.country;
const navigate = useNavigate({ from: Route.fullPath });
const setCountry = (c: string) => navigate({ search: { country: c }, replace: true });
const detail = useQuery(browseTitleQuery(id, country));
const services = useQuery(servicesQuery);
if (detail.error)
return (
<>
<BackLink />
<Card className="mt-6 p-4">
<div className="flex items-start gap-2 text-sm text-red-600 dark:text-red-400">
<Icon name="alert" size={16} className="mt-0.5" />
<span>{mask.text(trackingErrorMessage(detail.error))}</span>
</div>
</Card>
</>
);
if (!detail.data)
return (
<>
<BackLink />
<div className="mt-10 flex justify-center text-neutral-400">
<Icon name="loader" size={24} spin />
</div>
</>
);
const { title, offers, countries } = detail.data;
const regionOptions = countries.includes(country) ? countries : [country, ...countries];
// Matched services first, each group A-Z by provider name.
const rows = offers.map((offer) => ({
offer,
match: matchOffer(offer.url, services.data ?? [])
}));
rows.sort(
(a, b) =>
Number(!a.match) - Number(!b.match) || a.offer.packageName.localeCompare(b.offer.packageName)
);
return (
<>
<BackLink />
<div className="mt-6 flex gap-6">
{title.posterUrl && (
<img
src={title.posterUrl}
alt={mask.title(title.title)}
className="redact-poster w-32 shrink-0 self-start rounded-lg border border-neutral-200 sm:w-40 dark:border-neutral-800"
/>
)}
<div className="min-w-0">
<h1 className="text-2xl font-semibold tracking-tight">{mask.title(title.title)}</h1>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Badge tone="accent">{title.objectType === 'MOVIE' ? 'Movie' : 'Show'}</Badge>
{title.year && <Badge>{mask.year(title.year)}</Badge>}
{title.runtime ? <Badge>{mask.minutes(title.runtime)} min</Badge> : null}
{title.genres.map((g) => (
<Badge key={g} tone="violet">
{g}
</Badge>
))}
</div>
{countries.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1" aria-label="Available in">
{countries.map((c) => (
<button
key={c}
onClick={() => setCountry(c)}
title={`Show availability in ${c}`}
className={`rounded-md px-1.5 py-0.5 font-mono text-xs font-medium transition-colors ${
c === country
? 'bg-accent-600 text-white'
: 'bg-neutral-100 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-900 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-100'
}`}
>
{c}
</button>
))}
</div>
)}
{title.synopsis && (
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-neutral-600 dark:text-neutral-300">
{mask.text(title.synopsis)}
</p>
)}
</div>
</div>
<h2 className="mt-8 text-lg font-semibold tracking-tight">Where to watch</h2>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Availability in{' '}
<select
value={country}
onChange={(e) => setCountry(e.target.value)}
aria-label="Region"
className="rounded-md border border-neutral-200 bg-white px-1.5 py-0.5 font-mono text-xs font-medium text-neutral-900 focus:border-accent-500 focus:ring-1 focus:ring-accent-500/30 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
>
{regionOptions.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
. Providers matching one of your services link straight to the title.
</p>
{offers.length === 0 ? (
<Card className="mt-4 p-5">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Not available anywhere in {country} right now.
</p>
</Card>
) : (
<Card className="mt-4 divide-y divide-neutral-100 dark:divide-neutral-800">
{rows.map(({ offer, match }) => {
const row = (
<>
{offer.iconUrl ? (
<img
src={offer.iconUrl}
alt=""
className="redact-img h-9 w-9 rounded-lg object-cover"
/>
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-neutral-100 text-neutral-400 dark:bg-neutral-800">
<Icon name="film" size={16} />
</div>
)}
{/* Incognito fakes matched rows from the service tag, so name and badge agree. */}
<span className="min-w-0 truncate font-medium text-neutral-900 dark:text-neutral-100">
{incognito && match
? mask.service(match.service)
: mask.service(offer.packageName)}
</span>
<span className="flex flex-1 flex-wrap gap-1">
{offer.monetization.map((m) => (
<Badge key={m} tone={m === 'FLATRATE' ? 'accent' : 'neutral'}>
{MONETIZATION[m] ?? m.toLowerCase()}
</Badge>
))}
</span>
{match ? (
<>
<Badge tone="green">{mask.service(match.service)}</Badge>
<Icon
name="chevron"
size={18}
className="shrink-0 text-neutral-300 dark:text-neutral-600"
/>
</>
) : (
<span className="text-xs text-neutral-400 dark:text-neutral-500">
no matching service
</span>
)}
</>
);
return match ? (
<Link
key={offer.packageId}
to="/title/$service/$id"
params={{ service: match.service, id: match.id }}
className="flex items-center gap-4 px-5 py-3.5 transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-800/50"
>
{row}
</Link>
) : (
<div key={offer.packageId} className="flex items-center gap-4 px-5 py-3.5 opacity-50">
{row}
</div>
);
})}
</Card>
)}
</>
);
}
const MONETIZATION: Record<string, string> = {
FLATRATE: 'Stream',
FREE: 'Free',
ADS: 'Free with ads',
RENT: 'Rent',
BUY: 'Buy',
CINEMA: 'Cinema'
};
function BackLink() {
return (
<Link
to="/"
className="inline-flex items-center gap-1 text-sm font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100"
>
<Icon name="chevron" size={16} className="rotate-180" />
Browse
</Link>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { useState } from 'react';
import Button from '$lib/components/Button';
import Card from '$lib/components/Card';
import EmptyState from '$lib/components/EmptyState';
import Icon from '$lib/components/Icon';
import PosterGrid from '$lib/components/PosterGrid';
import { useSettings } from '$lib/config';
import { browseSearchQuery } from '$lib/queries';
import { useMask } from '$lib/stores/incognito';
import { trackingErrorMessage } from '$lib/tracking/client';
export const Route = createFileRoute('/discover')({ component: Discover });
function Discover() {
const mask = useMask();
const { country } = useSettings();
const [input, setInput] = useState('');
const [q, setQ] = useState('');
const search = useQuery({ ...browseSearchQuery(q, country), enabled: q !== '' });
const results = search.data?.results;
return (
<>
<h1 className="text-2xl font-semibold tracking-tight">Discover</h1>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
Search JustWatch and see where a title streams in {country}.
</p>
<form
onSubmit={(e) => {
e.preventDefault();
setQ(input.trim());
}}
className="mt-6 flex gap-3"
>
<div className="relative min-w-64 flex-1">
<div className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-neutral-400">
<Icon name="search" size={16} />
</div>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
type="search"
placeholder="Search movies and shows..."
className="redact w-full rounded-lg border border-neutral-200 bg-white py-2 pr-3 pl-9 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-accent-500 focus:ring-2 focus:ring-accent-500/30 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
<Button type="submit" disabled={search.isFetching || !input.trim()}>
{search.isFetching ? (
<>
<Icon name="loader" spin />
Searching...
</>
) : (
'Search'
)}
</Button>
</form>
<div className="mt-8">
{search.error ? (
<Card className="p-4">
<div className="flex items-start gap-2 text-sm text-red-600 dark:text-red-400">
<Icon name="alert" size={16} className="mt-0.5" />
<span>{mask.text(trackingErrorMessage(search.error))}</span>
</div>
</Card>
) : results ? (
results.length === 0 ? (
<Card>
<EmptyState icon="search" title="No results" description="Try a different query." />
</Card>
) : (
<PosterGrid titles={results} showType />
)
) : (
<Card>
<EmptyState
icon="search"
title="Search JustWatch"
description="Find a title, then see which of your services carry it."
/>
</Card>
)}
</div>
</>
);
}
+43 -4
View File
@@ -7,7 +7,9 @@ import Button from '$lib/components/Button';
import Card from '$lib/components/Card';
import EmptyState from '$lib/components/EmptyState';
import Icon from '$lib/components/Icon';
import { profilesQuery, servicesQuery } from '$lib/queries';
import PosterGrid from '$lib/components/PosterGrid';
import { useSettings } from '$lib/config';
import { popularQuery, profilesQuery, servicesQuery } from '$lib/queries';
import { useMask } from '$lib/stores/incognito';
export const Route = createFileRoute('/')({ component: Browse });
@@ -80,6 +82,10 @@ function Browse() {
const canSubmit = Boolean(query.trim() && service);
// JustWatch popular titles, from this app's own server (not the unshackle API).
const { country } = useSettings();
const popular = useQuery(popularQuery(country));
return (
<>
<h1 className="text-2xl font-semibold tracking-tight">Browse</h1>
@@ -141,7 +147,7 @@ function Browse() {
value={query}
onChange={(e) => setQuery(e.target.value)}
type="search"
placeholder="Search titles, or paste a title ID / URL"
placeholder="Search titles, or paste a title ID / URL..."
className="redact w-full rounded-lg border border-neutral-200 bg-white py-2 pr-3 pl-9 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-accent-500 focus:ring-2 focus:ring-accent-500/30 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
/>
</div>
@@ -150,7 +156,7 @@ function Browse() {
{search.isPending ? (
<>
<Icon name="loader" spin />
Searching
Searching...
</>
) : (
'Search'
@@ -166,7 +172,7 @@ function Browse() {
{openDirect.isPending ? (
<>
<Icon name="loader" spin />
Opening
Opening...
</>
) : (
<>
@@ -302,6 +308,39 @@ function Browse() {
</Card>
)
) : null}
{popular.data ? (
<div className="mt-10 space-y-8">
<section>
<PopularHeader label="Popular movies" type="movies" />
<PosterGrid titles={popular.data.movies} />
</section>
<section>
<PopularHeader label="Popular TV" type="tv" />
<PosterGrid titles={popular.data.shows} />
</section>
</div>
) : popular.error ? (
<p className="mt-10 text-sm text-neutral-400 dark:text-neutral-500">
Couldn't load popular titles from JustWatch.
</p>
) : null}
</>
);
}
function PopularHeader({ label, type }: { label: string; type: 'movies' | 'tv' }) {
return (
<div className="flex items-baseline gap-3">
<h2 className="text-lg font-semibold tracking-tight">{label}</h2>
<Link
to="/popular/$type"
params={{ type }}
className="inline-flex items-center gap-0.5 text-sm font-medium text-accent-600 hover:text-accent-700 dark:text-accent-400 dark:hover:text-accent-300"
>
All
<Icon name="chevron" size={16} />
</Link>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useQuery } from '@tanstack/react-query';
import { createFileRoute, Link } from '@tanstack/react-router';
import Card from '$lib/components/Card';
import EmptyState from '$lib/components/EmptyState';
import Icon from '$lib/components/Icon';
import PosterGrid from '$lib/components/PosterGrid';
import { useSettings } from '$lib/config';
import { popularListQuery } from '$lib/queries';
import { useMask } from '$lib/stores/incognito';
import { trackingErrorMessage } from '$lib/tracking/client';
export const Route = createFileRoute('/popular/$type')({ component: PopularPage });
function PopularPage() {
const mask = useMask();
const { type } = Route.useParams();
const { country } = useSettings();
const kind = type === 'movies' ? 'movies' : type === 'tv' ? 'tv' : null;
const list = useQuery({ ...popularListQuery(kind ?? 'movies', country), enabled: kind !== null });
if (!kind)
return (
<Card className="mt-6">
<EmptyState
icon="film"
title="Not found"
description="Try /popular/movies or /popular/tv."
/>
</Card>
);
return (
<>
<Link
to="/"
className="inline-flex items-center gap-1 text-sm font-medium text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100"
>
<Icon name="chevron" size={16} className="rotate-180" />
Browse
</Link>
<h1 className="mt-4 text-2xl font-semibold tracking-tight">
{kind === 'movies' ? 'Popular movies' : 'Popular TV'}
</h1>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
What's popular on JustWatch in {country} right now.
</p>
<div className="mt-6">
{list.error ? (
<Card className="p-4">
<div className="flex items-start gap-2 text-sm text-red-600 dark:text-red-400">
<Icon name="alert" size={16} className="mt-0.5" />
<span>{mask.text(trackingErrorMessage(list.error))}</span>
</div>
</Card>
) : list.data ? (
<PosterGrid titles={list.data.results} />
) : (
<div className="mt-10 flex justify-center text-neutral-400">
<Icon name="loader" size={24} spin />
</div>
)}
</div>
</>
);
}
+35 -3
View File
@@ -46,11 +46,22 @@ function SettingsPage() {
const queryClient = useQueryClient();
const current = useSettings();
const [draft, setDraft] = useState({ apiUrl: current.apiUrl, apiKey: current.apiKey });
const [draft, setDraft] = useState({
apiUrl: current.apiUrl,
apiKey: current.apiKey,
country: current.country
});
const [saved, setSaved] = useState(false);
function save() {
settings.set({ apiUrl: draft.apiUrl.trim(), apiKey: draft.apiKey.trim() });
settings.set({
apiUrl: draft.apiUrl.trim(),
apiKey: draft.apiKey.trim(),
// Anything that isn't a 2-letter code silently falls back to US.
country: /^[A-Za-z]{2}$/.test(draft.country.trim())
? draft.country.trim().toUpperCase()
: 'US'
});
// Everything cached was fetched from the old base URL / key. removeQueries, not
// clear(), so the in-flight "test & save" mutation that calls this survives.
queryClient.removeQueries();
@@ -187,12 +198,33 @@ function SettingsPage() {
</p>
</div>
<div>
<label
htmlFor="country"
className="block text-sm font-medium text-neutral-700 dark:text-neutral-200"
>
Country
</label>
<input
id="country"
type="text"
value={draft.country}
onChange={(e) => setDraft((d) => ({ ...d, country: e.target.value }))}
placeholder="US"
maxLength={2}
className="mt-1.5 w-24 rounded-lg border border-neutral-200 bg-white px-3 py-2 font-mono text-sm text-neutral-900 uppercase placeholder:text-neutral-400 focus:border-accent-500 focus:ring-2 focus:ring-accent-500/30 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100"
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
2-letter code for JustWatch availability on the browse pages.
</p>
</div>
<div className="flex items-center gap-3 pt-1">
<Button onClick={() => test.mutate()} disabled={test.isPending}>
{test.isPending ? (
<>
<Icon name="loader" spin />
Testing
Testing...
</>
) : (
<>
+7 -1
View File
@@ -1,4 +1,4 @@
// Request/response helpers shared by the tracking routes.
// Request/response helpers shared by the tracking and browse routes.
/** Error bodies use `error`, the same key the unshackle API uses, so errorMessage() reads them. */
export function fail(status: number, error: string): Response {
@@ -20,3 +20,9 @@ export async function readJson(request: Request): Promise<Record<string, unknown
export function str(v: unknown): string | null {
return typeof v === 'string' && v.trim() !== '' ? v.trim() : null;
}
/** Uppercased 2-letter `country` query param (default US), or null when malformed. */
export function countryParam(request: Request): string | null {
const c = (new URL(request.url).searchParams.get('country') || 'US').toUpperCase();
return /^[A-Z]{2}$/.test(c) ? c : null;
}
+278
View File
@@ -0,0 +1,278 @@
// Server-side JustWatch client. apis.justwatch.com sends no CORS headers, so the browser
// can never call it directly; the api.browse.* routes proxy through here. No key, no auth:
// country and language are plain GraphQL variables.
import { cleanOfferUrl } from '$lib/browse/match';
import type {
BrowseOffer,
BrowseSearchResponse,
BrowseTitle,
PopularResponse,
TitleDetailResponse
} from '$lib/browse/types';
import { fail } from './http';
const ENDPOINT = 'https://apis.justwatch.com/graphql';
const IMAGES = 'https://images.justwatch.com';
export class JustWatchError extends Error {
constructor(
public status: number,
message: string
) {
super(message);
this.name = 'JustWatchError';
}
}
/** Error → Response for the api.browse.* handlers; unknown failures read as a bad gateway. */
export function jwFail(e: unknown): Response {
const status = e instanceof JustWatchError && e.status >= 400 ? e.status : 502;
return fail(status, e instanceof Error ? e.message : String(e));
}
async function graphql<T>(query: string, variables: Record<string, unknown>): Promise<T> {
let res: Response;
try {
res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables })
});
} catch {
throw new JustWatchError(0, 'Cannot reach JustWatch.');
}
if (!res.ok)
throw new JustWatchError(res.status, `JustWatch replied ${res.status} ${res.statusText}.`);
const body = (await res.json()) as { data?: T; errors?: { message?: string }[] };
if (body.errors?.length)
throw new JustWatchError(502, body.errors[0].message || 'JustWatch query failed.');
if (!body.data) throw new JustWatchError(502, 'Empty JustWatch response.');
return body.data;
}
// ponytail: in-memory TTL cache, a restart just refetches; sqlite if that ever hurts
const cache = new Map<string, { at: number; data: unknown }>();
async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
const hit = cache.get(key);
if (hit && Date.now() - hit.at < ttlMs) return hit.data as T;
const data = await fn();
cache.set(key, { at: Date.now(), data });
return data;
}
/** Image paths come back relative; the CDN host is implied. */
const img = (path: string | null | undefined): string | null =>
path ? (path.startsWith('http') ? path : `${IMAGES}${path}`) : null;
interface ContentNode {
title?: string | null;
originalReleaseYear?: number | null;
shortDescription?: string | null;
runtime?: number | null;
fullPath?: string | null;
genres?: { translation?: string | null }[] | null;
posterUrl?: string | null;
backdrops?: { backdropUrl?: string | null }[] | null;
}
interface TitleNode {
id: string;
objectType: 'MOVIE' | 'SHOW';
content?: ContentNode | null;
}
function toTitle(node: TitleNode): BrowseTitle {
const c = node.content ?? {};
return {
id: node.id,
objectType: node.objectType,
title: c.title ?? '(untitled)',
year: c.originalReleaseYear ?? null,
posterUrl: img(c.posterUrl),
genres: (c.genres ?? []).map((g) => g?.translation ?? '').filter(Boolean),
synopsis: c.shortDescription ?? null,
runtime: c.runtime ?? null,
backdropUrl: img(c.backdrops?.[0]?.backdropUrl)
};
}
// One query for both the popular grid and search: searchQuery is just a filter key.
const TITLES_QUERY = `
query Titles($country: Country!, $language: Language!, $filter: TitleFilter!, $first: Int!, $profile: PosterProfile) {
popularTitles(country: $country, filter: $filter, first: $first, sortBy: POPULAR, sortRandomSeed: 0) {
edges { node {
id objectType
content(country: $country, language: $language) {
title originalReleaseYear shortDescription
genres { translation(language: $language) }
posterUrl(profile: $profile, format: JPG)
}
} }
}
}`;
interface TitlesData {
popularTitles?: { edges?: { node: TitleNode }[] | null } | null;
}
async function fetchTitles(
country: string,
filter: Record<string, unknown>,
first: number
): Promise<BrowseTitle[]> {
const data = await graphql<TitlesData>(TITLES_QUERY, {
country,
language: 'en',
filter,
first,
profile: 'S332'
});
return (data.popularTitles?.edges ?? []).map((e) => toTitle(e.node));
}
export function getPopular(country: string): Promise<PopularResponse> {
return cached(`popular:${country}`, 6 * 3600_000, async () => {
const [movies, shows] = await Promise.all([
fetchTitles(country, { objectTypes: ['MOVIE'] }, 5),
fetchTitles(country, { objectTypes: ['SHOW'] }, 5)
]);
return { movies, shows };
});
}
/** The "All" page for one type. */
export function popularList(
country: string,
objectType: 'MOVIE' | 'SHOW'
): Promise<BrowseSearchResponse> {
// ponytail: fixed 40 titles, add paging if anyone ever scrolls past them
return cached(`popular:${country}:${objectType}`, 6 * 3600_000, async () => ({
results: await fetchTitles(country, { objectTypes: [objectType] }, 40)
}));
}
export function searchTitles(query: string, country: string): Promise<BrowseSearchResponse> {
return cached(`search:${country}:${query.toLowerCase()}`, 10 * 60_000, async () => ({
results: await fetchTitles(country, { searchQuery: query, includeTitlesWithoutUrl: true }, 20)
}));
}
const DETAIL_QUERY = `
query TitleDetail($nodeId: ID!, $country: Country!, $language: Language!) {
node(id: $nodeId) {
... on MovieOrShow {
id objectType
content(country: $country, language: $language) {
title originalReleaseYear shortDescription runtime fullPath
genres { translation(language: $language) }
posterUrl(profile: S718, format: JPG)
backdrops(profile: S1920, format: JPG) { backdropUrl }
}
offers(country: $country, platform: WEB, filter: {}) {
monetizationType standardWebURL
package { packageId clearName technicalName icon(profile: S100) }
}
}
}
}`;
/**
* Countries a title has a JustWatch page in, the nearest thing to a "where is this
* available" signal. From the REST side of the API; [] on any failure.
*/
async function titleCountries(fullPath: string): Promise<string[]> {
try {
const res = await fetch(
`https://apis.justwatch.com/content/urls?path=${encodeURIComponent(fullPath)}`
);
if (!res.ok) return [];
const body = (await res.json()) as { href_lang_tags?: { locale?: string }[] };
const codes = (body.href_lang_tags ?? [])
.map((t) => t.locale?.split('_').pop() ?? '')
.filter((c) => /^[A-Z]{2}$/.test(c));
return [...new Set(codes)].sort();
} catch {
return [];
}
}
/**
* JustWatch's Apple TV offer for a show deep-links its first episode, and ATV can't resolve
* episode ids. The episode page embeds its show's id, so swap the URL for the show page.
* Returns the input on any failure.
*/
// ponytail: HTML scrape of tv.apple.com, switch to Apple's uts API if it ever breaks
async function appleShowUrl(episodeUrl: string): Promise<string> {
try {
const res = await fetch(episodeUrl, { headers: { 'User-Agent': 'Mozilla/5.0' } });
if (!res.ok) return episodeUrl;
const html = await res.text();
const showId = html.match(/"showId"\s*:\s*"(umc\.cmc\.[a-z0-9]+)"/)?.[1];
if (!showId) return episodeUrl;
// Prefer the page's own show link (has the real slug); fall back to a dummy slug,
// which tv.apple.com redirects and the ATV title_regex accepts either way.
const path = html.match(
new RegExp(`(?:/[a-z]{2})?/show/[a-z0-9-]+/${showId.replaceAll('.', '\\.')}`)
)?.[0];
return `https://tv.apple.com${path ?? `/us/show/show/${showId}`}`;
} catch {
return episodeUrl;
}
}
interface OfferNode {
monetizationType?: string | null;
standardWebURL?: string | null;
package?: {
packageId?: number | null;
clearName?: string | null;
technicalName?: string | null;
icon?: string | null;
} | null;
}
interface DetailData {
node?: (TitleNode & { offers?: OfferNode[] | null }) | null;
}
export function getTitle(nodeId: string, country: string): Promise<TitleDetailResponse> {
return cached(`title:${country}:${nodeId}`, 3600_000, async () => {
const data = await graphql<DetailData>(DETAIL_QUERY, { nodeId, country, language: 'en' });
const node = data.node;
if (!node?.id) throw new JustWatchError(404, 'Title not found on JustWatch.');
// Offers repeat per presentation type (SD/HD/4K) and monetization: one row per
// provider, monetization types merged, streamable providers first.
const rank = (m: string) => {
const i = ['FLATRATE', 'FREE', 'ADS', 'RENT', 'BUY'].indexOf(m);
return i === -1 ? 99 : i;
};
const byPkg = new Map<number, BrowseOffer>();
const raw = (node.offers ?? []).filter((o) => o.standardWebURL && o.package?.packageId);
raw.sort((a, b) => rank(a.monetizationType ?? '') - rank(b.monetizationType ?? ''));
for (const o of raw) {
const pkg = o.package!;
const mon = o.monetizationType ?? 'FLATRATE';
const existing = byPkg.get(pkg.packageId!);
if (existing) {
if (!existing.monetization.includes(mon)) existing.monetization.push(mon);
continue;
}
byPkg.set(pkg.packageId!, {
url: cleanOfferUrl(o.standardWebURL!),
packageId: pkg.packageId!,
packageName: pkg.clearName || pkg.technicalName || 'Unknown',
packageTechnicalName: pkg.technicalName ?? '',
iconUrl: img(pkg.icon),
monetization: [mon]
});
}
const offers = [...byPkg.values()];
for (const offer of offers) {
if (node.objectType === 'SHOW' && /tv\.apple\.com\/.*\/episode\//.test(offer.url))
offer.url = await appleShowUrl(offer.url);
}
const countries = node.content?.fullPath ? await titleCountries(node.content.fullPath) : [];
return { title: toTitle(node), offers, countries };
});
}