// JSON API. // // GET /api?t=movie&q= -> TMDB detail (movie) + IMDb note // GET /api?t=tv&q= -> TMDB detail (tv) + IMDb note // GET /api?t=imdb&q= -> redirect-style: returns movie or tv detail // GET /api?t=providers&type=movie&q= -> watch providers JSON // GET /api?t=search&q= -> ranked search results import { readFile } from 'node:fs/promises'; import { LRUCache } from 'lru-cache'; import { IMDB_URL, MOVIE_API_URL, MOVIE_URL, POSTER_URL, TV_API_URL, TV_URL } from '../config.js'; import { formatCurrency, formatRuntime, pad2 } from '../lib/format.js'; import { lookupImdb } from '../lib/imdbMapping.js'; import { getRatings, lookupRating } from '../lib/imdbRatings.js'; import { searchCacheHits, searchCacheMisses } from '../lib/metrics.js'; import { entryPath, justwatchPath } from '../lib/paths.js'; import { parseQuery } from '../lib/queryParser.js'; import { search as runSearch } from '../lib/searchEngine.js'; import { filterAndLower } from '../lib/titleFilter.js'; const searchCache = new LRUCache({ max: 1000, ttl: 1000 * 60 * 60 }); async function getDetail(type, id) { try { const buf = await readFile(entryPath(type, id), 'utf8'); return JSON.parse(buf); } catch { return null; } } async function getProviders(type, id) { try { return JSON.parse(await readFile(justwatchPath(type, id), 'utf8')); } catch { return null; } } async function handleEntry(type, id) { const obj = await getDetail(type, id); if (!obj) return { error: 'Not found' }; const imdb = type === 'movie' ? obj.imdb_id : obj?.external_ids?.imdb_id; if (imdb) { const ratings = await getRatings(); const row = ratings.get(imdb); if (row) { obj.note_imdb = row[0].trim(); obj.vote_imdb = row[1].trim(); } } return obj; } async function handleImdbLookup(imdbId) { const found = await lookupImdb(imdbId); if (!found) return { error: 'IMDb id not found in local mappings' }; return handleEntry(found.type, found.tmdb); } async function handleSearch(query) { const cached = searchCache.get(query); if (cached) { searchCacheHits.inc(); return cached; } searchCacheMisses.inc(); const parsed = parseQuery(query); if (!parsed) return null; if (parsed.error) return { error: parsed.error }; const { type, titlein, yearin, episodein } = parsed; const filteredTitleIn = filterAndLower(titlein); const matches = await runSearch(type, filteredTitleIn, yearin); if (!matches.length) { const out = { error: 'Not found in localized and original titles database' }; searchCache.set(query, out); return out; } const ratings = await getRatings(); const movietvurl = type === 'movie' ? MOVIE_URL : TV_URL; const movietvurlapi = type === 'movie' ? MOVIE_API_URL : TV_API_URL; const results = []; for (const m of matches) { const detail = await getDetail(type, m.tmdb); if (!detail) continue; const item = {}; if (m.filteredTitle) { item.title = m.title; item.years = m.year; } if (m.filteredEnglishTitle) item.english_title = m.englishTitle; if (m.filteredOriginalTitle) item.original_title = m.originalTitle; item.poster = `${POSTER_URL}/${detail.poster_path}`; item.poster_path = detail.poster_path; let genres = ''; if (Array.isArray(detail.genres)) { for (const g of detail.genres) genres += `${g.name} `; } if (genres) item.genres = genres; let countries = ''; if (Array.isArray(detail.production_countries)) { for (const c of detail.production_countries) countries += `${c.iso_3166_1} `; } if (countries) item.countries = countries; if (detail.runtime) item.runtime = formatRuntime(detail.runtime); const imdb = !episodein ? detail.imdb_id : detail?.external_ids?.imdb_id; if (imdb) { const { rating, votes } = lookupRating(ratings, imdb); item.imdb_id = imdb; item.imdb_url = `${IMDB_URL}/${imdb}`; item.note_imdb = rating; item.vote_imdb = votes; } item.tmdb_id = m.tmdb; item.tmdb_url = `${movietvurl}/${m.tmdb}`; item.api_url = `${movietvurlapi}${m.tmdb}`; item.note_tmdb = Math.round((parseFloat(detail.vote_average) || 0) * 10) / 10; item.vote_tmdb = parseInt(detail.vote_count, 10) || 0; if (detail.budget || detail.revenue) { item.budget = formatCurrency(detail.budget); item.revenue = formatCurrency(detail.revenue); } if (episodein && Array.isArray(detail.seasons)) { let lastSeason; for (const s of detail.seasons) { const sn = pad2(s.season_number || 0); const ec = pad2(s.episode_count || 0); lastSeason = `S${sn}E${ec}`; } if (lastSeason) item.season = lastSeason; } if (detail.tagline) item.tagline = detail.tagline; if (detail.overview) item.overview = detail.overview; results.push(item); } const out = { results }; searchCache.set(query, out); return out; } async function handle(req, reply) { reply.header('Access-Control-Allow-Origin', '*'); reply.header('Content-Type', 'application/json; charset=utf-8'); const t = req.query?.t; const q = req.query?.q; if (t === 'movie' || t === 'tv') { if (!q) return {}; const id = parseInt(q, 10); if (!Number.isInteger(id)) return {}; return handleEntry(t, id); } if (t === 'imdb') { if (!q) return {}; return handleImdbLookup(String(q)); } if (t === 'providers') { const type = req.query?.type; if (!q || (type !== 'movie' && type !== 'tv')) { return { error: 'providers requires type=movie|tv and q=' }; } const id = parseInt(q, 10); if (!Number.isInteger(id)) return {}; const data = await getProviders(type, id); return data ?? { error: 'Providers not found' }; } if (t === 'search') { if (!q) return reply.send(''); return (await handleSearch(q)) ?? {}; } return reply.send(''); } export default async function apiRoutes(fastify) { fastify.get('/api', handle); fastify.get('/api.php', handle); }