mirror of
https://github.com/unshackle-dl/unshackle-ui.git
synced 2026-09-14 11:51:51 +02:00
feat(browse): justwatch discovery pages with service-matched offers
This commit is contained in:
Generated
+2
-2
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "unshackle-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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)}`
|
||||
)
|
||||
};
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user