201 lines
6.0 KiB
JavaScript
201 lines
6.0 KiB
JavaScript
// JSON API.
|
|
//
|
|
// GET /api?t=movie&q=<id> -> TMDB detail (movie) + IMDb note
|
|
// GET /api?t=tv&q=<id> -> TMDB detail (tv) + IMDb note
|
|
// GET /api?t=imdb&q=<imdb_id> -> redirect-style: returns movie or tv detail
|
|
// GET /api?t=providers&type=movie&q=<id> -> watch providers JSON
|
|
// GET /api?t=search&q=<query> -> 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=<id>' };
|
|
}
|
|
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);
|
|
}
|