From 3563de52e99815e496924083c6bf61b85f123f2b Mon Sep 17 00:00:00 2001 From: unfr Date: Thu, 23 Apr 2026 08:37:48 +0200 Subject: [PATCH] Portage complet PHP/Bash vers Node.js (Fastify + worker_threads) --- .env.example | 34 ++ .gitignore | 52 ++ config.js | 61 ++ cron/ambiguity.js | 104 ++++ cron/buildSearch.js | 133 +++++ cron/imdbRatings.js | 36 ++ cron/justwatchSync.js | 93 +++ cron/run.sh | 19 + cron/runAll.js | 59 ++ cron/tmdb2imdb.js | 52 ++ cron/tmdbExports.js | 56 ++ cron/tmdbSync.js | 168 ++++++ lib/format.js | 21 + lib/http.js | 66 ++ lib/imdbRatings.js | 49 ++ lib/mbLevenshtein.js | 47 ++ lib/paths.js | 26 + lib/queryParser.js | 85 +++ lib/searchEngine.js | 80 +++ lib/searchWorker.js | 109 ++++ lib/titleFilter.js | 29 + package-lock.json | 1328 +++++++++++++++++++++++++++++++++++++++++ package.json | 27 + routes/api.js | 156 +++++ routes/index.js | 162 +++++ routes/search.js | 144 +++++ server.js | 60 ++ test/helpers.test.js | 92 +++ 28 files changed, 3348 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 config.js create mode 100644 cron/ambiguity.js create mode 100644 cron/buildSearch.js create mode 100644 cron/imdbRatings.js create mode 100644 cron/justwatchSync.js create mode 100755 cron/run.sh create mode 100644 cron/runAll.js create mode 100644 cron/tmdb2imdb.js create mode 100644 cron/tmdbExports.js create mode 100644 cron/tmdbSync.js create mode 100644 lib/format.js create mode 100644 lib/http.js create mode 100644 lib/imdbRatings.js create mode 100644 lib/mbLevenshtein.js create mode 100644 lib/paths.js create mode 100644 lib/queryParser.js create mode 100644 lib/searchEngine.js create mode 100644 lib/searchWorker.js create mode 100644 lib/titleFilter.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 routes/api.js create mode 100644 routes/index.js create mode 100644 routes/search.js create mode 100644 server.js create mode 100644 test/helpers.test.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..acc2b84 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# --- Secrets / runtime (obligatoires) --- +TMDB_API_KEY=your_tmdb_api_key_here +PROXYTMDB_PASSWORD=change_me +# 32 caracteres. Generer avec : +# node -e "console.log(require('crypto').randomBytes(32).toString('base64').slice(0,32))" +SESSION_SECRET=change_me_to_a_random_32_chars_str + +# --- Serveur --- +PORT=3000 +HOST=0.0.0.0 +PAGE_TITLE=Index protégé + +# --- URLs externes (laisse les defauts sauf si tu changes de domaine) --- +#TMDB_API_BASE=https://api.themoviedb.org/3 +#TMDB_EXPORTS_BASE=http://files.tmdb.org/p/exports +#IMDB_DATASETS_BASE=https://datasets.imdbws.com +#MOVIE_URL=https://www.themoviedb.org/movie +#TV_URL=https://www.themoviedb.org/tv +#MOVIE_API_URL=https://tmdb.uklm.xyz/api?t=movie&q= +#TV_API_URL=https://tmdb.uklm.xyz/api?t=tv&q= +#POSTER_URL=https://image.tmdb.org/t/p/w200 +#NO_POSTER_URL=https://www.serveurperso.com/stats/noposter.jpg +#IMDB_URL=https://www.imdb.com/title + +# --- Reglages cron / recherche (defauts conserves de la version PHP) --- +#CHANGES_DAYS=3 +#NB_SEARCH_PARTS=8 +#NB_WORKERS=8 +#TITLE_TOLERANCE=40 +#LEV_INS=10 +#LEV_REP=12 +#LEV_DEL=10 +#LEV_SCALE=10 +#YEAR_TOLERANCE=1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0850623 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Node +node_modules/ +npm-debug.log* + +# Logs runtime +cron.txt +lastcron.txt +*.log + +# Donnees generees (telechargees / construites par le cron) +imdbratings.tsv +imdbratings.tsv.tmp +title.ratings.tsv +title.ratings.tsv.gz + +# Exports TMDb quotidiens +tmdbintegral/movie.json +tmdbintegral/tv.json +tmdbintegral/movie.json.tmp +tmdbintegral/tv.json.tmp + +# Mappings TMDb <-> IMDb +tmdbintegral/movie2imdb.json +tmdbintegral/tv2imdb.json +tmdbintegral/imdb2movie.json +tmdbintegral/imdb2tv.json + +# Chunks de recherche +tmdbintegral/searchmovie*.json +tmdbintegral/searchtv*.json + +# Ambiguites +tmdbintegral/ambiguitymovie.csv +tmdbintegral/ambiguitytv.csv + +# Cache complet TMDb (~1700 dossiers x 1000 fichiers JSON) +tmdbintegral/movie/ +tmdbintegral/tv/ +tmdbintegral/justwatchmovie/ +tmdbintegral/justwatchtv/ + +# IDE / OS / outils +.claude/ +.specstory/ +.vscode/ +.idea/ +.DS_Store +Thumbs.db + +# Secrets +.env +.env.local diff --git a/config.js b/config.js new file mode 100644 index 0000000..3c18a1e --- /dev/null +++ b/config.js @@ -0,0 +1,61 @@ +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export const ROOT = __dirname; +export const TMDBINTEGRAL_DIR = join(ROOT, 'tmdbintegral'); +export const MOVIE_DIR = join(TMDBINTEGRAL_DIR, 'movie'); +export const TV_DIR = join(TMDBINTEGRAL_DIR, 'tv'); +export const JUSTWATCH_MOVIE_DIR = join(TMDBINTEGRAL_DIR, 'justwatchmovie'); +export const JUSTWATCH_TV_DIR = join(TMDBINTEGRAL_DIR, 'justwatchtv'); +export const IMDB_RATINGS = join(ROOT, 'imdbratings.tsv'); +export const CRON_TXT = join(ROOT, 'cron.txt'); +export const LASTCRON_TXT = join(ROOT, 'lastcron.txt'); + +function required(name) { + const v = process.env[name]; + if (!v) throw new Error(`Variable d'environnement manquante: ${name} (voir .env.example)`); + return v; +} + +function int(name, def) { + const v = process.env[name]; + return v ? parseInt(v, 10) : def; +} + +function str(name, def) { + return process.env[name] ?? def; +} + +// Secrets / runtime +export const TMDB_API_KEY = required('TMDB_API_KEY'); +export const PASSWORD = required('PROXYTMDB_PASSWORD'); +export const SESSION_SECRET = required('SESSION_SECRET'); +export const PORT = int('PORT', 3000); +export const HOST = str('HOST', '0.0.0.0'); + +// URLs externes +export const TMDB_API_BASE = str('TMDB_API_BASE', 'https://api.themoviedb.org/3'); +export const TMDB_EXPORTS_BASE = str('TMDB_EXPORTS_BASE', 'http://files.tmdb.org/p/exports'); +export const IMDB_DATASETS_BASE = str('IMDB_DATASETS_BASE', 'https://datasets.imdbws.com'); +export const MOVIE_URL = str('MOVIE_URL', 'https://www.themoviedb.org/movie'); +export const TV_URL = str('TV_URL', 'https://www.themoviedb.org/tv'); +export const MOVIE_API_URL = str('MOVIE_API_URL', 'https://tmdb.uklm.xyz/api?t=movie&q='); +export const TV_API_URL = str('TV_API_URL', 'https://tmdb.uklm.xyz/api?t=tv&q='); +export const POSTER_URL = str('POSTER_URL', 'https://image.tmdb.org/t/p/w200'); +export const NO_POSTER_URL = str('NO_POSTER_URL', 'https://www.serveurperso.com/stats/noposter.jpg'); +export const IMDB_URL = str('IMDB_URL', 'https://www.imdb.com/title'); + +// Reglages cron / recherche +export const CHANGES_DAYS = int('CHANGES_DAYS', 3); +export const NB_SEARCH_PARTS = int('NB_SEARCH_PARTS', 8); +export const NB_WORKERS = int('NB_WORKERS', 8); +export const TITLE_TOLERANCE = int('TITLE_TOLERANCE', 40); +export const LEV_INS = int('LEV_INS', 10); +export const LEV_REP = int('LEV_REP', 12); +export const LEV_DEL = int('LEV_DEL', 10); +export const LEV_SCALE = int('LEV_SCALE', 10); +export const YEAR_TOLERANCE = int('YEAR_TOLERANCE', 1); + +export const TITLE = str('PAGE_TITLE', 'Index protégé'); diff --git a/cron/ambiguity.js b/cron/ambiguity.js new file mode 100644 index 0000000..0a73719 --- /dev/null +++ b/cron/ambiguity.js @@ -0,0 +1,104 @@ +// Port of tmdbintegral/ambiguity.php +// Detects pairs of distinct TMDb ids whose filtered titles collide and whose +// years are within YEARTOLERANCE. + +import { readFileSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { TMDBINTEGRAL_DIR, NB_SEARCH_PARTS } from '../config.js'; + +const TMDB = 0; +const FILTEREDTITLE = 4; +const FILTEREDENGLISHTITLE = 5; +const FILTEREDORIGINALTITLE = 6; +const YEAR = 7; + +export async function buildAmbiguity(type, nbParts = NB_SEARCH_PARTS) { + const yearTolerance = type === 'tv' ? 200 : 1; + const out = join(TMDBINTEGRAL_DIR, `ambiguity${type}.csv`); + + const database = []; + for (let p = 0; p < nbParts; p++) { + const file = join(TMDBINTEGRAL_DIR, `search${type}${p}.json`); + const chunk = JSON.parse(readFileSync(file, 'utf8')); + for (const e of chunk) database.push(e); + } + + const tmdbs = []; + const filteredTitles = []; + const languages = []; + const years = []; + for (const db of database) { + const fr = db[FILTEREDTITLE]; + const en = db[FILTEREDENGLISHTITLE]; + const vo = db[FILTEREDORIGINALTITLE]; + if (fr) { tmdbs.push(db[TMDB]); filteredTitles.push(fr); years.push(db[YEAR][0]); languages.push('FR'); } + if (en) { tmdbs.push(db[TMDB]); filteredTitles.push(en); years.push(db[YEAR][0]); languages.push('EN'); } + if (vo) { tmdbs.push(db[TMDB]); filteredTitles.push(vo); years.push(db[YEAR][0]); languages.push('VO'); } + } + + // PHP: array_multisort(filteredtitles, years, tmdbs, languages) + // Sort indices by (filteredTitle ASC, year ASC, tmdb ASC, language ASC). + const idx = filteredTitles.map((_, i) => i); + idx.sort((a, b) => { + if (filteredTitles[a] < filteredTitles[b]) return -1; + if (filteredTitles[a] > filteredTitles[b]) return 1; + if (years[a] !== years[b]) return years[a] - years[b]; + if (tmdbs[a] !== tmdbs[b]) return tmdbs[a] - tmdbs[b]; + if (languages[a] < languages[b]) return -1; + if (languages[a] > languages[b]) return 1; + return 0; + }); + + const sortedTmdbs = idx.map((i) => tmdbs[i]); + const sortedFiltered = idx.map((i) => filteredTitles[i]); + const sortedYears = idx.map((i) => years[i]); + const sortedLanguages = idx.map((i) => languages[i]); + + let oldTmdb = 0; + let nbTmdbs = 0; + let oldFiltered = ''; + let ambiguities = []; + const lines = []; + + const flush = () => { + if (nbTmdbs >= 2) { + for (const a1 of ambiguities) { + for (const a2 of ambiguities) { + if (a1[0] !== a2[0] && Math.abs(a1[1] - a2[1]) <= yearTolerance) { + lines.push(`${a1[0]};${a1[2]};${a2[0]};${a2[2]}`); + } + } + } + } + ambiguities = []; + nbTmdbs = 0; + }; + + for (let i = 0; i < sortedFiltered.length; i++) { + if (sortedTmdbs[i] !== oldTmdb) nbTmdbs++; + oldTmdb = sortedTmdbs[i]; + + if (sortedFiltered[i] !== oldFiltered) { + flush(); + } + oldFiltered = sortedFiltered[i]; + ambiguities.push([sortedTmdbs[i], sortedYears[i], sortedLanguages[i]]); + } + flush(); + + await writeFile(out, lines.length ? lines.join('\n') + '\n' : ''); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const type = process.argv[2]; + const nb = parseInt(process.argv[3] || String(NB_SEARCH_PARTS), 10); + if (type !== 'movie' && type !== 'tv') { + console.error('Usage: node cron/ambiguity.js movie|tv [nbParts]'); + process.exit(1); + } + buildAmbiguity(type, nb).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/cron/buildSearch.js b/cron/buildSearch.js new file mode 100644 index 0000000..5a9fde5 --- /dev/null +++ b/cron/buildSearch.js @@ -0,0 +1,133 @@ +// Port of tmdbintegral/search.php +// Builds the chunked search database files (searchmovieN.json / searchtvN.json). +// +// Each entry has the same positional shape as the PHP version: +// [TMDB, TITLE, ENGLISHTITLE, ORIGINALTITLE, +// FILTEREDTITLE, FILTEREDENGLISHTITLE, FILTEREDORIGINALTITLE, +// YEARS[], POPULARITY] +// so the runtime search worker can use the same indices. + +import { createReadStream, existsSync, readFileSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { createInterface } from 'node:readline'; +import { join } from 'node:path'; +import { TMDBINTEGRAL_DIR, NB_SEARCH_PARTS } from '../config.js'; +import { entryPath } from '../lib/paths.js'; +import { filterTitle } from '../lib/titleFilter.js'; +import { mbStrlen } from '../lib/mbLevenshtein.js'; + +function lower(s) { return s.toLocaleLowerCase(); } + +function extractEnglishTitle(detail, type) { + const tr = detail?.translations?.translations; + if (!Array.isArray(tr)) return ''; + for (const t of tr) { + if (t.iso_639_1 === 'en') { + return type === 'movie' ? (t.data?.title || '') : (t.data?.name || ''); + } + } + return ''; +} + +function buildEntry(masterObj, detail, type) { + const tmdb = masterObj.id; + const popularity = parseFloat(masterObj.popularity) || 0; + + let title, originalTitle, englishTitle; + const years = []; + + if (type === 'movie') { + const date = String(detail.release_date || '').split('-'); + years.push(parseInt(date[0], 10) || 0); + title = detail.title || ''; + originalTitle = detail.original_title || ''; + englishTitle = extractEnglishTitle(detail, 'movie'); + } else { + const date = String(detail.first_air_date || '').split('-'); + years.push(parseInt(date[0], 10) || 0); + title = detail.name || ''; + originalTitle = detail.original_name || ''; + englishTitle = extractEnglishTitle(detail, 'tv'); + if (Array.isArray(detail.seasons)) { + for (const s of detail.seasons) { + const sd = String(s.air_date || '').split('-'); + const sy = parseInt(sd[0], 10); + if (sy) years.push(sy); + } + } + } + + if (!years[0]) return null; + + let ft = filterTitle(title); + let fe = filterTitle(englishTitle); + let fo = filterTitle(originalTitle); + + if (!ft && !fe && !fo) return null; + + if (ft && mbStrlen(ft) / mbStrlen(title) < 0.5) ft = ''; + if (fe && mbStrlen(fe) / mbStrlen(englishTitle) < 0.5) fe = ''; + if (fo && mbStrlen(fo) / mbStrlen(originalTitle) < 0.5) fo = ''; + + // Dedupe years preserving order (PHP array_values(array_unique($years))) + const seen = new Set(); + const uniqYears = []; + for (const y of years) { + if (!seen.has(y)) { seen.add(y); uniqYears.push(y); } + } + + return [ + tmdb, + title, + englishTitle, + originalTitle, + lower(ft), + lower(fe), + lower(fo), + uniqYears, + popularity, + ]; +} + +export async function buildSearch(type, nbParts = NB_SEARCH_PARTS) { + const indexFile = join(TMDBINTEGRAL_DIR, `${type}.json`); + const database = []; + + const stream = createReadStream(indexFile, { encoding: 'utf8' }); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + + for await (const line of rl) { + if (!line) continue; + let masterObj; + try { masterObj = JSON.parse(line); } catch { continue; } + const path = entryPath(type, masterObj.id); + if (!existsSync(path)) continue; + let detail; + try { detail = JSON.parse(readFileSync(path, 'utf8')); } catch { continue; } + const entry = buildEntry(masterObj, detail, type); + if (entry) database.push(entry); + } + + const partSize = Math.ceil(database.length / nbParts); + const writes = []; + for (let p = 0; p < nbParts; p++) { + const chunk = database.slice(p * partSize, (p + 1) * partSize); + const out = join(TMDBINTEGRAL_DIR, `search${type}${p}.json`); + console.log(`Writing ${chunk.length} entries to search${type}${p}.json`); + writes.push(writeFile(out, JSON.stringify(chunk))); + } + await Promise.all(writes); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const type = process.argv[2]; + const nb = parseInt(process.argv[3] || String(NB_SEARCH_PARTS), 10); + if (type !== 'movie' && type !== 'tv') { + console.error('Usage: node cron/buildSearch.js movie|tv [nbParts]'); + process.exit(1); + } + buildSearch(type, nb).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/cron/imdbRatings.js b/cron/imdbRatings.js new file mode 100644 index 0000000..bff6b2e --- /dev/null +++ b/cron/imdbRatings.js @@ -0,0 +1,36 @@ +import { createWriteStream } from 'node:fs'; +import { rename } from 'node:fs/promises'; +import { pipeline } from 'node:stream/promises'; +import { createGunzip } from 'node:zlib'; +import { Readable } from 'node:stream'; +import { join } from 'node:path'; +import { ROOT, IMDB_DATASETS_BASE, IMDB_RATINGS } from '../config.js'; + +const FILE = 'title.ratings.tsv'; + +export async function syncImdbRatings() { + const url = `${IMDB_DATASETS_BASE}/${FILE}.gz`; + const tmpPath = join(ROOT, `${FILE}.tmp`); + + console.log(`Downloading: "${url}"`); + const res = await fetch(url); + if (!res.ok || !res.body) { + throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); + } + + await pipeline( + Readable.fromWeb(res.body), + createGunzip(), + createWriteStream(tmpPath), + ); + + await rename(tmpPath, IMDB_RATINGS); + console.log(`Wrote ${IMDB_RATINGS}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + syncImdbRatings().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/cron/justwatchSync.js b/cron/justwatchSync.js new file mode 100644 index 0000000..f5f49ce --- /dev/null +++ b/cron/justwatchSync.js @@ -0,0 +1,93 @@ +// Port of tmdbintegral/justwatch.php + +import { createReadStream, existsSync, readdirSync, unlinkSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { createInterface } from 'node:readline'; +import { join } from 'node:path'; +import { + TMDBINTEGRAL_DIR, JUSTWATCH_MOVIE_DIR, JUSTWATCH_TV_DIR, TMDB_API_KEY, TMDB_API_BASE, +} from '../config.js'; +import { Limiter } from '../lib/http.js'; +import { justwatchDir, justwatchPath, bucket } from '../lib/paths.js'; + +const DOWNLOAD_CONCURRENCY = 16; + +async function readMasterIds(type) { + const file = join(TMDBINTEGRAL_DIR, `${type}.json`); + const ids = []; + const stream = createReadStream(file, { encoding: 'utf8' }); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + for await (const line of rl) { + if (!line) continue; + try { + const obj = JSON.parse(line); + if (typeof obj.id === 'number') ids.push(obj.id); + } catch { /* ignore */ } + } + return ids; +} + +async function ensureDir(dir) { + if (!existsSync(dir)) await mkdir(dir, { recursive: true }); +} + +async function downloadProvider(type, id) { + const dir = justwatchDir(type, id); + await ensureDir(dir); + const path = justwatchPath(type, id); + const url = `${TMDB_API_BASE}/${type}/${id}/watch/providers?api_key=${TMDB_API_KEY}`; + console.log(`Downloading: "justwatch${type}/${bucket(id)}/${id}.json"`); + const res = await fetch(url); + if (!res.ok) { + console.log(`Failed to retrieve TMDb data: "${url}"`); + return; + } + const text = await res.text(); + await writeFile(path, text); +} + +function removeOrphans(type, ids) { + const baseDir = type === 'movie' ? JUSTWATCH_MOVIE_DIR : JUSTWATCH_TV_DIR; + const expected = new Set(ids); + let buckets; + try { buckets = readdirSync(baseDir); } catch { return; } + for (const b of buckets) { + let entries; + try { entries = readdirSync(join(baseDir, b)); } catch { continue; } + for (const fname of entries) { + if (!fname.endsWith('.json')) continue; + const id = parseInt(fname.slice(0, -5), 10); + if (!Number.isInteger(id)) continue; + if (!expected.has(id)) { + const p = join(baseDir, b, fname); + console.log(`Removing: "justwatch${type}/${b}/${fname}"`); + try { unlinkSync(p); } catch { /* ignore */ } + } + } + } +} + +export async function syncType(type) { + const ids = await readMasterIds(type); + const limiter = new Limiter(DOWNLOAD_CONCURRENCY); + const tasks = []; + for (const id of ids) { + if (existsSync(justwatchPath(type, id))) continue; + tasks.push(limiter.run(() => downloadProvider(type, id))); + } + await Promise.allSettled(tasks); + ids.sort((a, b) => a - b); + removeOrphans(type, ids); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const type = process.argv[2]; + if (type !== 'movie' && type !== 'tv') { + console.error('Usage: node cron/justwatchSync.js movie|tv'); + process.exit(1); + } + syncType(type).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/cron/run.sh b/cron/run.sh new file mode 100755 index 0000000..1b5663d --- /dev/null +++ b/cron/run.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# +# Wrapper de lancement du cron pour environnement nvm. +# Cron n'a pas le PATH de nvm — ce script charge nvm puis lance le cron Node. +# +# Utilisation crontab : +# 13 13 * * * /home/matt/_WEB/proxytmdb/cron/run.sh > /home/matt/_WEB/proxytmdb/lastcron.txt 2>&1 + +set -e + +export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" +# shellcheck source=/dev/null +[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" + +# Bascule sur la version "default" de nvm (suit nvm alias default) +nvm use default >/dev/null + +cd "$(dirname "$0")/.." +exec node --env-file-if-exists=.env cron/runAll.js diff --git a/cron/runAll.js b/cron/runAll.js new file mode 100644 index 0000000..6751007 --- /dev/null +++ b/cron/runAll.js @@ -0,0 +1,59 @@ +// Port of cron.sh + tmdbintegral/tmdbintegral.sh +// +// Pipeline: +// 1. Refresh imdbratings.tsv +// 2. Download daily TMDb exports (movie.json, tv.json) +// 3. In parallel: tmdbSync(movie+tv), justwatchSync(movie+tv) +// 4. In parallel: tmdb2imdb(movie+tv), buildSearch(movie+tv) +// 5. In parallel: ambiguity(movie+tv) +// +// Writes cron.txt at start/end (mirrors cron.sh). + +import { writeFileSync, appendFileSync } from 'node:fs'; +import { CRON_TXT } from '../config.js'; +import { syncImdbRatings } from './imdbRatings.js'; +import { syncExports } from './tmdbExports.js'; +import { syncType as syncTmdb } from './tmdbSync.js'; +import { syncType as syncJustwatch } from './justwatchSync.js'; +import { buildMapping } from './tmdb2imdb.js'; +import { buildSearch } from './buildSearch.js'; +import { buildAmbiguity } from './ambiguity.js'; + +function dateStamp() { + return new Date().toString(); +} + +export async function runAll() { + writeFileSync(CRON_TXT, `Started At ${dateStamp()}\n`); + + await syncImdbRatings(); + await syncExports(); + + await Promise.all([ + syncTmdb('movie'), + syncTmdb('tv'), + syncJustwatch('movie'), + syncJustwatch('tv'), + ]); + + await Promise.all([ + buildMapping('movie'), + buildMapping('tv'), + buildSearch('movie'), + buildSearch('tv'), + ]); + + await Promise.all([ + buildAmbiguity('movie'), + buildAmbiguity('tv'), + ]); + + appendFileSync(CRON_TXT, `Finished At ${dateStamp()}\n`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runAll().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/cron/tmdb2imdb.js b/cron/tmdb2imdb.js new file mode 100644 index 0000000..8c02d76 --- /dev/null +++ b/cron/tmdb2imdb.js @@ -0,0 +1,52 @@ +// Port of tmdbintegral/tmdb2imdb.php +// Builds bidirectional TMDb <-> IMDb id mappings from cached detail files. + +import { createReadStream, existsSync, readFileSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { createInterface } from 'node:readline'; +import { join } from 'node:path'; +import { TMDBINTEGRAL_DIR } from '../config.js'; +import { entryPath } from '../lib/paths.js'; + +export async function buildMapping(type) { + const inputFile = join(TMDBINTEGRAL_DIR, `${type}.json`); + const out1 = join(TMDBINTEGRAL_DIR, `${type}2imdb.json`); + const out2 = join(TMDBINTEGRAL_DIR, `imdb2${type}.json`); + + const data1 = {}; + const data2 = {}; + + const stream = createReadStream(inputFile, { encoding: 'utf8' }); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + + for await (const line of rl) { + if (!line) continue; + let obj; + try { obj = JSON.parse(line); } catch { continue; } + const tmdb = obj.id; + const path = entryPath(type, tmdb); + if (!existsSync(path)) continue; + let detail; + try { detail = JSON.parse(readFileSync(path, 'utf8')); } catch { continue; } + const imdb = detail?.external_ids?.imdb_id; + if (imdb) { + data1[tmdb] = imdb; + data2[imdb] = tmdb; + } + } + + await writeFile(out1, JSON.stringify(data1)); + await writeFile(out2, JSON.stringify(data2)); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const type = process.argv[2]; + if (type !== 'movie' && type !== 'tv') { + console.error('Usage: node cron/tmdb2imdb.js movie|tv'); + process.exit(1); + } + buildMapping(type).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/cron/tmdbExports.js b/cron/tmdbExports.js new file mode 100644 index 0000000..9deccab --- /dev/null +++ b/cron/tmdbExports.js @@ -0,0 +1,56 @@ +import { createWriteStream } from 'node:fs'; +import { rename } from 'node:fs/promises'; +import { pipeline } from 'node:stream/promises'; +import { createGunzip } from 'node:zlib'; +import { Readable } from 'node:stream'; +import { join } from 'node:path'; +import { TMDBINTEGRAL_DIR, TMDB_EXPORTS_BASE } from '../config.js'; + +function formatMMDDYYYY(date) { + const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(date.getUTCDate()).padStart(2, '0'); + const yyyy = date.getUTCFullYear(); + return `${mm}_${dd}_${yyyy}`; +} + +async function tryDownload(url, outPath) { + console.log(`Downloading: "${url}"`); + const res = await fetch(url); + if (res.status === 403 || res.status === 404) { + console.log(`Not published yet (HTTP ${res.status}): ${url}`); + return false; + } + if (!res.ok || !res.body) { + throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); + } + const tmp = `${outPath}.tmp`; + await pipeline(Readable.fromWeb(res.body), createGunzip(), createWriteStream(tmp)); + await rename(tmp, outPath); + console.log(`Wrote ${outPath}`); + return true; +} + +// TMDb publishes the daily export around 08:00 UTC. If we run before that, the +// current-day file returns 403. Try today, then fall back to yesterday. +async function downloadExport(prefix, outName) { + const now = new Date(); + const yesterday = new Date(now.getTime() - 86400 * 1000); + const out = join(TMDBINTEGRAL_DIR, outName); + for (const d of [now, yesterday]) { + const url = `${TMDB_EXPORTS_BASE}/${prefix}_${formatMMDDYYYY(d)}.json.gz`; + if (await tryDownload(url, out)) return; + } + throw new Error(`No TMDb ${prefix} export available for today or yesterday`); +} + +export async function syncExports() { + await downloadExport('movie_ids', 'movie.json'); + await downloadExport('tv_series_ids', 'tv.json'); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + syncExports().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/cron/tmdbSync.js b/cron/tmdbSync.js new file mode 100644 index 0000000..6a28b2e --- /dev/null +++ b/cron/tmdbSync.js @@ -0,0 +1,168 @@ +// Port of tmdbintegral/tmdbintegral.php +// +// 1. Fetch /changes for the last CHANGES_DAYS to find recently-modified entries +// whose local cache file is older than CHANGES_DAYS (so we re-download them). +// 2. Stream .json line-by-line, ensure each id has a local detail file +// (downloading it if missing or flagged for update). +// 3. Walk through every numeric id < max(tmdbs) and remove orphan files that +// no longer appear in the master list. + +import { createReadStream, createWriteStream, existsSync, statSync, readdirSync, unlinkSync } from 'node:fs'; +import { mkdir, stat, writeFile, unlink } from 'node:fs/promises'; +import { createInterface } from 'node:readline'; +import { join } from 'node:path'; +import { + TMDBINTEGRAL_DIR, MOVIE_DIR, TV_DIR, TMDB_API_KEY, TMDB_API_BASE, CHANGES_DAYS, +} from '../config.js'; +import { fetchJson, Limiter } from '../lib/http.js'; +import { entryDir, entryPath, bucket } from '../lib/paths.js'; + +const CHANGES_SECS = CHANGES_DAYS * 24 * 3600; +const DOWNLOAD_CONCURRENCY = 16; + +function ymd(date) { + const y = date.getUTCFullYear(); + const m = String(date.getUTCMonth() + 1).padStart(2, '0'); + const d = String(date.getUTCDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function appendResponse(type) { + return type === 'tv' + ? 'credits,aggregate_credits,external_ids,release_dates,translations,images,videos' + : 'credits,external_ids,release_dates,translations,images,videos'; +} + +function detailUrl(type, id) { + const base = `${TMDB_API_BASE}/${type}`; + return `${base}/${id}?api_key=${TMDB_API_KEY}&append_to_response=${appendResponse(type)}&include_image_language=fr,null,en&language=fr-FR`; +} + +async function findChanges(type) { + const now = new Date(); + const start = new Date(now.getTime() - CHANGES_DAYS * 86400 * 1000); + const startdate = ymd(start); + const enddate = ymd(now); + const baseUrl = `${TMDB_API_BASE}/${type}/changes?api_key=${TMDB_API_KEY}&start_date=${startdate}&end_date=${enddate}&page=`; + + const updates = new Set(); + let total = 1; + for (let page = 1; page <= total; page++) { + const url = `${baseUrl}${page}`; + console.log(`Downloading: "${url}"`); + const obj = await fetchJson(url); + if (!obj) { + console.log(`Failed to retrieve TMDb data: "${baseUrl}"`); + continue; + } + if (typeof obj.total_pages === 'number') total = obj.total_pages; + if (!Array.isArray(obj.results)) continue; + + for (const change of obj.results) { + const id = change.id; + const path = entryPath(type, id); + if (!existsSync(path)) continue; + let st; + try { st = statSync(path); } catch { continue; } + // PHP uses filectime; on Linux ctime tracks metadata changes too, but the + // intent is "last time the local file was refreshed". We use mtime which + // is closer to that intent in JS (writeFile updates mtime). + const ageSecs = (Date.now() - st.mtimeMs) / 1000; + if (ageSecs >= CHANGES_SECS) { + const days = Math.floor(ageSecs / 86400); + const hours = Math.floor((ageSecs % 86400) / 3600); + const minutes = Math.floor((ageSecs % 3600) / 60); + console.log(`Updating: "${type}/${bucket(id)}/${id}.json" ${days} days, ${hours} hours, ${minutes} minutes`); + updates.add(id); + } + } + } + return updates; +} + +async function readMasterIds(type) { + const file = join(TMDBINTEGRAL_DIR, `${type}.json`); + const ids = []; + const stream = createReadStream(file, { encoding: 'utf8' }); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + for await (const line of rl) { + if (!line) continue; + try { + const obj = JSON.parse(line); + if (typeof obj.id === 'number') ids.push(obj.id); + } catch { /* ignore malformed lines */ } + } + return ids; +} + +async function ensureDir(dir) { + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } +} + +async function downloadDetail(type, id) { + const dir = entryDir(type, id); + await ensureDir(dir); + const path = entryPath(type, id); + console.log(`Downloading: "${type}/${bucket(id)}/${id}.json"`); + const url = detailUrl(type, id); + const res = await fetch(url); + if (!res.ok) { + console.log(`Failed to retrieve TMDb data: "${url}"`); + return; + } + const text = await res.text(); + await writeFile(path, text); +} + +function removeOrphans(type, sortedIds) { + // Walk every bucket directory once, build a set of expected ids, delete the rest. + const baseDir = type === 'movie' ? MOVIE_DIR : TV_DIR; + const expected = new Set(sortedIds); + let buckets; + try { buckets = readdirSync(baseDir); } catch { return; } + for (const b of buckets) { + let entries; + try { entries = readdirSync(join(baseDir, b)); } catch { continue; } + for (const fname of entries) { + if (!fname.endsWith('.json')) continue; + const id = parseInt(fname.slice(0, -5), 10); + if (!Number.isInteger(id)) continue; + if (!expected.has(id)) { + const p = join(baseDir, b, fname); + console.log(`Removing: "${type}/${b}/${fname}"`); + try { unlinkSync(p); } catch { /* ignore */ } + } + } + } +} + +export async function syncType(type) { + const updates = await findChanges(type); + const ids = await readMasterIds(type); + + const limiter = new Limiter(DOWNLOAD_CONCURRENCY); + const tasks = []; + for (const id of ids) { + const path = entryPath(type, id); + if (!updates.has(id) && existsSync(path)) continue; + tasks.push(limiter.run(() => downloadDetail(type, id))); + } + await Promise.allSettled(tasks); + + ids.sort((a, b) => a - b); + removeOrphans(type, ids); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const type = process.argv[2]; + if (type !== 'movie' && type !== 'tv') { + console.error('Usage: node cron/tmdbSync.js movie|tv'); + process.exit(1); + } + syncType(type).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/lib/format.js b/lib/format.js new file mode 100644 index 0000000..36795ac --- /dev/null +++ b/lib/format.js @@ -0,0 +1,21 @@ +// Money formatting (Intl.NumberFormat replaces PHP's NumberFormatter::CURRENCY). + +const FMT = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + minimumFractionDigits: 0, +}); + +export function formatCurrency(n) { + return FMT.format(n || 0); +} + +export function pad2(n) { + return n < 10 ? `0${n}` : String(n); +} + +export function formatRuntime(runtime) { + if (!runtime) return ''; + return `${Math.floor(runtime / 60)} h ${pad2(runtime % 60)} min`; +} diff --git a/lib/http.js b/lib/http.js new file mode 100644 index 0000000..dcc7313 --- /dev/null +++ b/lib/http.js @@ -0,0 +1,66 @@ +// Tiny fetch wrapper with retry and concurrency limiter. + +export async function fetchText(url, { retries = 3, timeoutMs = 30000 } = {}) { + let lastErr; + for (let attempt = 0; attempt <= retries; attempt++) { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + const res = await fetch(url, { signal: ac.signal }); + clearTimeout(timer); + if (!res.ok) { + if (res.status === 404) return null; + throw new Error(`HTTP ${res.status} ${res.statusText}`); + } + return await res.text(); + } catch (err) { + clearTimeout(timer); + lastErr = err; + if (attempt < retries) { + await new Promise((r) => setTimeout(r, 500 * (attempt + 1))); + } + } + } + console.error(`fetchText failed: ${url} :: ${lastErr?.message}`); + return null; +} + +export async function fetchJson(url, opts) { + const text = await fetchText(url, opts); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +export class Limiter { + constructor(max) { + this.max = max; + this.active = 0; + this.queue = []; + } + run(fn) { + return new Promise((resolve, reject) => { + const tryRun = () => { + if (this.active >= this.max) { + this.queue.push(tryRun); + return; + } + this.active++; + Promise.resolve() + .then(fn) + .then( + (v) => { this.active--; resolve(v); this._next(); }, + (e) => { this.active--; reject(e); this._next(); }, + ); + }; + tryRun(); + }); + } + _next() { + const next = this.queue.shift(); + if (next) next(); + } +} diff --git a/lib/imdbRatings.js b/lib/imdbRatings.js new file mode 100644 index 0000000..d2b217b --- /dev/null +++ b/lib/imdbRatings.js @@ -0,0 +1,49 @@ +import { createReadStream, statSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import { IMDB_RATINGS } from '../config.js'; + +let cache = null; +let cacheMtime = 0; + +export async function loadRatings(filePath = IMDB_RATINGS) { + const map = new Map(); + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = createInterface({ input: stream, crlfDelay: Infinity }); + let first = true; + for await (const line of rl) { + if (first) { first = false; continue; } + if (!line) continue; + const tab1 = line.indexOf('\t'); + if (tab1 < 0) continue; + const tab2 = line.indexOf('\t', tab1 + 1); + if (tab2 < 0) continue; + const id = line.slice(0, tab1); + const rating = line.slice(tab1 + 1, tab2); + const votes = line.slice(tab2 + 1); + map.set(id, [rating, votes]); + } + return map; +} + +export async function getRatings() { + try { + const st = statSync(IMDB_RATINGS); + if (cache && st.mtimeMs === cacheMtime) return cache; + cache = await loadRatings(IMDB_RATINGS); + cacheMtime = st.mtimeMs; + return cache; + } catch (err) { + if (cache) return cache; + throw err; + } +} + +export function lookupRating(map, imdbId) { + if (!imdbId) return { rating: 0, votes: 0 }; + const row = map.get(imdbId); + if (!row) return { rating: 0, votes: 0 }; + return { + rating: parseFloat(row[0]) || 0, + votes: parseInt(row[1], 10) || 0, + }; +} diff --git a/lib/mbLevenshtein.js b/lib/mbLevenshtein.js new file mode 100644 index 0000000..5edddea --- /dev/null +++ b/lib/mbLevenshtein.js @@ -0,0 +1,47 @@ +// UTF-8-safe Levenshtein distance with custom insertion/replacement/deletion costs. +// Iterates by Unicode code point (matches the PHP mb_levenshtein behaviour). + +export function mbLevenshtein(s1, s2, costIns = 1, costRep = 1, costDel = 1) { + const a = [...s1]; + const b = [...s2]; + const la = a.length; + const lb = b.length; + + if (la === 0) return lb * costIns; + if (lb === 0) return la * costDel; + + let prev = new Array(lb + 1); + let curr = new Array(lb + 1); + for (let j = 0; j <= lb; j++) prev[j] = j * costIns; + + for (let i = 1; i <= la; i++) { + curr[0] = i * costDel; + for (let j = 1; j <= lb; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : costRep; + const del = prev[j] + costDel; + const ins = curr[j - 1] + costIns; + const rep = prev[j - 1] + cost; + curr[j] = del < ins ? (del < rep ? del : rep) : (ins < rep ? ins : rep); + } + [prev, curr] = [curr, prev]; + } + return prev[lb]; +} + +export function mbLevenshteinRatio(s1, s2, costIns = 1, costRep = 1, costDel = 1) { + const l1 = [...s1].length; + const l2 = [...s2].length; + const size = Math.max(l1, l2); + if (!size) return 0; + if (!s1) return l2 / size; + if (!s2) return l1 / size; + return 1 - mbLevenshtein(s1, s2, costIns, costRep, costDel) / size; +} + +export function mbStrlen(s) { + return [...s].length; +} + +export function mbStrtolower(s) { + return s.toLocaleLowerCase(); +} diff --git a/lib/paths.js b/lib/paths.js new file mode 100644 index 0000000..a206b13 --- /dev/null +++ b/lib/paths.js @@ -0,0 +1,26 @@ +import { join } from 'node:path'; +import { MOVIE_DIR, TV_DIR, JUSTWATCH_MOVIE_DIR, JUSTWATCH_TV_DIR } from '../config.js'; + +export function bucket(id) { + return String(Math.floor(id / 1000)); +} + +export function entryPath(type, id) { + const base = type === 'movie' ? MOVIE_DIR : TV_DIR; + return join(base, bucket(id), `${id}.json`); +} + +export function entryDir(type, id) { + const base = type === 'movie' ? MOVIE_DIR : TV_DIR; + return join(base, bucket(id)); +} + +export function justwatchPath(type, id) { + const base = type === 'movie' ? JUSTWATCH_MOVIE_DIR : JUSTWATCH_TV_DIR; + return join(base, bucket(id), `${id}.json`); +} + +export function justwatchDir(type, id) { + const base = type === 'movie' ? JUSTWATCH_MOVIE_DIR : JUSTWATCH_TV_DIR; + return join(base, bucket(id)); +} diff --git a/lib/queryParser.js b/lib/queryParser.js new file mode 100644 index 0000000..ea1f727 --- /dev/null +++ b/lib/queryParser.js @@ -0,0 +1,85 @@ +// Replicates the query parsing logic shared by api.php and search.php: +// - extract a year (last (19|20)\d{2} match, ignoring 1080/2160) +// - extract an episode marker (SxxExxx, SxxExx, Sxx, partN, NxN, Exxx) +// - choose movie vs tv accordingly +// - extract titlein from the bytes before the year/episode + +import { FILTER_RE } from './titleFilter.js'; + +const YEAR_RE = /(19|20)\d{2}/g; +// Single-pass regex matching the PHP behaviour: +// - S/s and E/e and "part" are case-insensitive ([Ss], [Ee], [Pp]art) +// - the lowercase 'x' in NxN, and uppercase 'E' in standalone Exxx, are case-sensitive +// Greedy left-to-right alternation means "S01E02" is consumed whole, so the +// trailing "E02" alternative cannot match inside it. +const EPISODE_RE = /[Ss][0-9]{1,2}.?[Ee][0-9]{1,3}|[Ss][0-9]{2}|[Pp]art\.?[0-9]{1,3}|[0-9]{1,2}x[0-9]{1,3}|E[0-9]{1,3}/g; + +// PHP uses byte offsets (substr). To stay byte-faithful, work on the UTF-8 bytes. +const utf8 = (s) => Buffer.from(s, 'utf8'); +const sliceBytes = (s, end) => utf8(s).slice(0, end).toString('utf8'); + +function findAll(re, str) { + const out = []; + re.lastIndex = 0; + let m; + while ((m = re.exec(str)) !== null) { + out.push({ value: m[0], byteOffset: Buffer.byteLength(str.slice(0, m.index), 'utf8') }); + if (m.index === re.lastIndex) re.lastIndex++; + } + return out; +} + +function stripFilter(s) { + return s.replace(FILTER_RE, ''); +} + +export function parseQuery(query) { + if (!query) return null; + + let yearin = 0; + let yearpos = -1; + let titlein = ''; + + const years = findAll(YEAR_RE, query).reverse(); + for (const m of years) { + if (m.value === '1080' || m.value === '2160') continue; + yearin = parseInt(m.value, 10); + yearpos = m.byteOffset; + titlein = sliceBytes(query, yearpos); + break; + } + + let episodein = ''; + let episodepos = -1; + const eps = findAll(EPISODE_RE, query).reverse(); + for (const m of eps) { + episodein = m.value; + episodepos = m.byteOffset; + break; + } + + if (episodein) { + if (!yearin) { + titlein = sliceBytes(query, episodepos); + } else if (episodepos > yearpos) { + titlein = sliceBytes(query, yearpos); + if (!stripFilter(titlein)) { + titlein = sliceBytes(query, episodepos); + yearin = 0; + } + } else { + titlein = sliceBytes(query, episodepos); + if (!stripFilter(titlein)) { + titlein = sliceBytes(query, yearpos); + episodein = ''; + } + } + } + + if (!yearin && !episodein) { + return { error: 'Year or episode not found in query', titlein, yearin, episodein }; + } + + const type = episodein ? 'tv' : 'movie'; + return { type, titlein, yearin, episodein }; +} diff --git a/lib/searchEngine.js b/lib/searchEngine.js new file mode 100644 index 0000000..b5b97c0 --- /dev/null +++ b/lib/searchEngine.js @@ -0,0 +1,80 @@ +// Spawns N worker threads (one per searchTYPEi.json chunk) and orchestrates +// queries across them. Workers are kept alive between requests so the chunks +// stay loaded in memory (replaces the per-request `php searchmultithreads.php` +// fork from the PHP version). + +import { Worker } from 'node:worker_threads'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import { existsSync } from 'node:fs'; +import { TMDBINTEGRAL_DIR, NB_WORKERS } from '../config.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const WORKER_PATH = join(__dirname, 'searchWorker.js'); + +const pools = new Map(); + +class WorkerPool { + constructor(type) { + this.type = type; + this.workers = []; + this.nextId = 1; + this.pending = new Map(); + for (let i = 0; i < NB_WORKERS; i++) { + const chunkPath = join(TMDBINTEGRAL_DIR, `search${type}${i}.json`); + if (!existsSync(chunkPath)) { + console.warn(`Missing search chunk: ${chunkPath}`); + continue; + } + const w = new Worker(WORKER_PATH, { workerData: { chunkPath } }); + w.on('message', (msg) => this._onMessage(msg)); + w.on('error', (err) => console.error(`Worker ${type}/${i} error:`, err)); + w.unref(); + this.workers.push(w); + } + } + + _onMessage(msg) { + const entry = this.pending.get(msg.id); + if (!entry) return; + if (msg.type === 'result') entry.results.push(...msg.results); + entry.remaining--; + if (entry.remaining === 0) { + this.pending.delete(msg.id); + entry.resolve(entry.results); + } + } + + search(payload) { + return new Promise((resolve) => { + const id = this.nextId++; + this.pending.set(id, { results: [], remaining: this.workers.length, resolve }); + for (const w of this.workers) { + w.postMessage({ type: 'search', id, payload }); + } + }); + } +} + +export function getPool(type) { + if (!pools.has(type)) pools.set(type, new WorkerPool(type)); + return pools.get(type); +} + +export async function search(type, filteredTitleIn, yearIn) { + const pool = getPool(type); + const results = await pool.search({ filteredTitleIn, yearIn }); + + // Sort by delta ASC, then -popularity ASC (i.e. popularity DESC), + // then deltaYear ASC, then tmdb ASC. Equivalent to PHP's + // array_multisort($deltas, $pops, $deltayears, $tmdbs, ...). + results.sort((a, b) => { + if (a.delta !== b.delta) return a.delta - b.delta; + if (a.pop !== b.pop) return a.pop - b.pop; + if (a.deltaYear !== b.deltaYear) return a.deltaYear - b.deltaYear; + return a.tmdb - b.tmdb; + }); + + return results; +} diff --git a/lib/searchWorker.js b/lib/searchWorker.js new file mode 100644 index 0000000..dbc56af --- /dev/null +++ b/lib/searchWorker.js @@ -0,0 +1,109 @@ +// Worker thread used by lib/searchEngine.js. Equivalent to one fork in +// searchmultithreads.php: load one search chunk and emit candidate matches. + +import { readFileSync } from 'node:fs'; +import { parentPort, workerData } from 'node:worker_threads'; +import { mbLevenshtein, mbStrlen } from './mbLevenshtein.js'; +import { + TITLE_TOLERANCE, LEV_INS, LEV_REP, LEV_DEL, LEV_SCALE, YEAR_TOLERANCE, +} from '../config.js'; + +const TMDB = 0; +const TITLE = 1; +const ENGLISHTITLE = 2; +const ORIGINALTITLE = 3; +const FILTEREDTITLE = 4; +const FILTEREDENGLISHTITLE = 5; +const FILTEREDORIGINALTITLE = 6; +const YEAR = 7; +const POPULARITY = 8; + +let chunkPath; +let chunk = null; + +if (workerData?.chunkPath) { + chunkPath = workerData.chunkPath; +} + +function loadChunk() { + if (chunk) return chunk; + chunk = JSON.parse(readFileSync(chunkPath, 'utf8')); + return chunk; +} + +function score(filteredIn, target, ftiLen) { + if (!target) return 0; + const tlen = mbStrlen(target); + return 100 - (mbLevenshtein(filteredIn, target, LEV_INS, LEV_REP, LEV_DEL) / + (Math.max(ftiLen, tlen) * LEV_SCALE)) * 100; +} + +function search({ filteredTitleIn, yearIn }) { + const db = loadChunk(); + const out = []; + const ftiLen = mbStrlen(filteredTitleIn); + + for (const row of db) { + let deltaYear = 0; + if (yearIn) { + let ok = false; + for (const y of row[YEAR]) { + const dy = Math.abs(yearIn - y); + if (dy <= YEAR_TOLERANCE) { ok = true; deltaYear = dy; break; } + } + if (!ok) continue; + } + + const fT = row[FILTEREDTITLE]; + const fE = row[FILTEREDENGLISHTITLE]; + const fO = row[FILTEREDORIGINALTITLE]; + + const pO = score(filteredTitleIn, fO, ftiLen); + + let pT; + if (fT) { + pT = (fT === fO) ? pO : score(filteredTitleIn, fT, ftiLen); + } else pT = 0; + + let pE; + if (fE) { + if (fE === fO) pE = pO; + else if (fE === fT) pE = pT; + else pE = score(filteredTitleIn, fE, ftiLen); + } else pE = 0; + + const dT = 100 - pT; + const dE = 100 - pE; + const dO = 100 - pO; + const delta = Math.min(dT, dE, dO); + if (delta > TITLE_TOLERANCE) continue; + + out.push({ + delta, + pop: -row[POPULARITY], + deltaYear, + tmdb: row[TMDB], + title: row[TITLE], + englishTitle: row[ENGLISHTITLE], + originalTitle: row[ORIGINALTITLE], + filteredTitle: fT, + filteredEnglishTitle: fE, + filteredOriginalTitle: fO, + year: row[YEAR][0], + }); + } + return out; +} + +if (parentPort) { + parentPort.on('message', (msg) => { + if (msg?.type === 'search') { + try { + const results = search(msg.payload); + parentPort.postMessage({ type: 'result', id: msg.id, results }); + } catch (err) { + parentPort.postMessage({ type: 'error', id: msg.id, error: err.message }); + } + } + }); +} diff --git a/lib/titleFilter.js b/lib/titleFilter.js new file mode 100644 index 0000000..274f65c --- /dev/null +++ b/lib/titleFilter.js @@ -0,0 +1,29 @@ +// Replicates the PHP search.php title normalization: +// - replace ligatures and superscripts +// - strip everything that is not Latin or 0-9 +// - lowercase + +const TITLE_SEARCHES = ['œ', 'Œ', 'æ', 'Æ', 'é', 'è', '²', '³', '⁴']; +const TITLE_REPLACES = ['oe', 'Oe', 'ae', 'Ae', 'é', 'è', '2', '3', '4']; + +const FILTER_RE = /[^\p{Script=Latin}0-9]+/gu; + +export function translit(s) { + if (!s) return ''; + let out = s; + for (let i = 0; i < TITLE_SEARCHES.length; i++) { + out = out.split(TITLE_SEARCHES[i]).join(TITLE_REPLACES[i]); + } + return out; +} + +export function filterTitle(s) { + if (!s) return ''; + return translit(s).replace(FILTER_RE, ''); +} + +export function filterAndLower(s) { + return filterTitle(s).toLocaleLowerCase(); +} + +export { FILTER_RE }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8663d5a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1328 @@ +{ + "name": "proxytmdb", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "proxytmdb", + "version": "1.0.0", + "dependencies": { + "@fastify/formbody": "^8.0.1", + "@fastify/secure-session": "^8.1.0", + "@fastify/static": "^8.0.4", + "fastify": "^5.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", + "integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cookie": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", + "integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^1.0.0", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/formbody": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-8.0.2.tgz", + "integrity": "sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-querystring": "^1.1.2", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/secure-session": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@fastify/secure-session/-/secure-session-8.3.0.tgz", + "integrity": "sha512-jyXbYWAp5MCaCA+7idsV+Op3kMN/oDtskZ0SE3QPl53Lo0GFF7p/lk1UEliGaT0OSX+RGSrd2gQlUu1ta1t7yQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/cookie": "^11.0.1", + "fastify-plugin": "^5.0.0", + "sodium-native": "^5.0.1" + }, + "bin": { + "secure-session": "genkey.js" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz", + "integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-8.3.0.tgz", + "integrity": "sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^0.5.4", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^11.0.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-addon-resolve": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.0.tgz", + "integrity": "sha512-sSd0jieRJlDaODOzj0oe0RjFVC1QI0ZIjGIdPkbrTXsdVVtENg14c+lHHAhHwmWCZ2nQlMhy8jA3Y5LYPc/isA==", + "license": "Apache-2.0", + "dependencies": { + "bare-module-resolve": "^1.10.0", + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, + "node_modules/bare-ansi-escapes": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/bare-ansi-escapes/-/bare-ansi-escapes-2.2.3.tgz", + "integrity": "sha512-02ES4/E2RbrtZSnHJ9LntBhYkLA6lPpSEeP8iqS3MccBIVhVBlEmruF1I7HZqx5Q8aiTeYfQVeqmrU9YO2yYoQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-stream": "^2.6.5" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-assert": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/bare-assert/-/bare-assert-1.2.0.tgz", + "integrity": "sha512-c6uvgvTJBspTDxtVnPgrBKmLgcpW3Fp72NVKDLg6oT4QjQbhGtvrkHMhGYMK1sh4vjBHOBmuUalyt9hSzV37fQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-inspect": "^3.1.2" + } + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-inspect": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/bare-inspect/-/bare-inspect-3.1.4.tgz", + "integrity": "sha512-jfW5KRA84o3REpI6Vr4nbvMn+hqVAw8GU1mMdRwUsY5yJovQamxYeKGVKGqdzs+8ZbG4jRzGUXP/3Ji/DnqfPg==", + "license": "Apache-2.0", + "dependencies": { + "bare-ansi-escapes": "^2.1.0", + "bare-type": "^1.0.0" + }, + "engines": { + "bare": ">=1.18.0" + } + }, + "node_modules/bare-module-resolve": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.1.tgz", + "integrity": "sha512-hbmAPyFpEq8FoZMd5sFO3u6MC5feluWoGE8YKlA8fCrl6mNtx68Wjg4DTiDJcqRJaovTvOYKfYngoBUnbaT7eg==", + "license": "Apache-2.0", + "dependencies": { + "bare-semver": "^1.0.0" + }, + "peerDependencies": { + "bare-url": "*" + }, + "peerDependenciesMeta": { + "bare-url": { + "optional": true + } + } + }, + "node_modules/bare-semver": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.0.3.tgz", + "integrity": "sha512-HS/A30bi2+PiRJfU6R4+Kp+6KeLSCSByjYM2iiobOKzLAvtu1CT+S8xWfiU7wz0erknjkUoC+yXy108tzIuP5Q==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz", + "integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bare-type/-/bare-type-1.1.0.tgz", + "integrity": "sha512-LdtnnEEYldOc87Dr4GpsKnStStZk3zfgoEMXy8yvEZkXrcCv9RtYDrUYWFsBQHtaB0s1EUWmcvS6XmEZYIj3Bw==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.2.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-addon": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.2.0.tgz", + "integrity": "sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA==", + "license": "Apache-2.0", + "dependencies": { + "bare-addon-resolve": "^1.3.0" + }, + "engines": { + "bare": ">=1.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sodium-native": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", + "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==", + "license": "MIT", + "dependencies": { + "bare-assert": "^1.2.0", + "require-addon": "^1.1.0", + "which-runtime": "^1.2.1" + }, + "engines": { + "bare": ">=1.16.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-runtime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/which-runtime/-/which-runtime-1.3.2.tgz", + "integrity": "sha512-5kwCfWml7+b2NO7KrLMhYihjRx0teKkd3yGp1Xk5Vaf2JGdSh+rgVhEALAD9c/59dP+YwJHXoEO7e8QPy7gOkw==", + "license": "Apache-2.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0559380 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "proxytmdb", + "version": "1.0.0", + "description": "Proxy/cache local de l'API TMDB avec notes IMDb et matching titre/annee/episode", + "type": "module", + "private": true, + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node --env-file-if-exists=.env server.js", + "cron": "node --env-file-if-exists=.env cron/runAll.js", + "cron:imdb": "node --env-file-if-exists=.env cron/imdbRatings.js", + "cron:tmdb": "node --env-file-if-exists=.env cron/tmdbSync.js", + "cron:justwatch": "node --env-file-if-exists=.env cron/justwatchSync.js", + "cron:tmdb2imdb": "node --env-file-if-exists=.env cron/tmdb2imdb.js", + "cron:search": "node --env-file-if-exists=.env cron/buildSearch.js", + "cron:ambiguity": "node --env-file-if-exists=.env cron/ambiguity.js", + "test": "node --env-file-if-exists=.env --test test/*.test.js" + }, + "dependencies": { + "@fastify/formbody": "^8.0.1", + "@fastify/secure-session": "^8.1.0", + "@fastify/static": "^8.0.4", + "fastify": "^5.2.0" + } +} diff --git a/routes/api.js b/routes/api.js new file mode 100644 index 0000000..8bd3af7 --- /dev/null +++ b/routes/api.js @@ -0,0 +1,156 @@ +// JSON API — replaces api.php. +// GET /api?t=movie&q= +// GET /api?t=tv&q= +// GET /api?t=search&q= + +import { readFile } from 'node:fs/promises'; +import { entryPath } from '../lib/paths.js'; +import { getRatings, lookupRating } from '../lib/imdbRatings.js'; +import { parseQuery } from '../lib/queryParser.js'; +import { filterAndLower } from '../lib/titleFilter.js'; +import { search as runSearch } from '../lib/searchEngine.js'; +import { formatCurrency, formatRuntime, pad2 } from '../lib/format.js'; +import { + POSTER_URL, MOVIE_URL, TV_URL, MOVIE_API_URL, TV_API_URL, IMDB_URL, +} from '../config.js'; + +async function getDetail(type, id) { + try { + const buf = await readFile(entryPath(type, id), 'utf8'); + return JSON.parse(buf); + } 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 handleSearch(query) { + 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) { + return { error: 'Not found in localized and original titles database' }; + } + + 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)) { + // PHP loops and overwrites $data['results'][$j]['season'] for each season, + // so only the LAST season is kept. Reproduce that behaviour. + 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); + } + + return { results }; +} + +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 === '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); +} diff --git a/routes/index.js b/routes/index.js new file mode 100644 index 0000000..92e4949 --- /dev/null +++ b/routes/index.js @@ -0,0 +1,162 @@ +// Login + protected directory listing — replaces index.php. + +import { readdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ROOT, TITLE, PASSWORD } from '../config.js'; + +const HIDDEN = new Set(['index.php', '.htaccess', 'node_modules', 'package.json', 'package-lock.json', 'config.js', 'server.js', 'lib', 'routes', 'cron', 'test', '.git']); + +function esc(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function formatBytes(bytes) { + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + let i = bytes > 0 ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0; + if (i >= units.length) i = units.length - 1; + return `${(bytes / Math.pow(1024, i)) || 0} ${units[i]}`; +} + +function pad(n) { return n < 10 ? `0${n}` : String(n); } +function fmtDate(ms) { + const d = new Date(ms); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function loginPage(error = '') { + return ` + + + + +${esc(TITLE)} + + + +
+

${esc(TITLE)}

+ + + +
${esc(error)}
+
+ +`; +} + +async function listingPage() { + const names = await readdir(ROOT); + const entries = []; + for (const name of names) { + if (HIDDEN.has(name)) continue; + if (name.startsWith('.')) continue; + let st; + try { st = await stat(join(ROOT, name)); } catch { continue; } + entries.push({ + name, + isDir: st.isDirectory(), + size: st.isFile() ? st.size : 0, + mtime: st.mtimeMs, + }); + } + entries.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const rows = entries.map((e) => { + const href = encodeURIComponent(e.name); + return ` + ${esc(e.name)}${e.isDir ? 'dossier' : ''} + ${e.isDir ? '—' : esc(formatBytes(e.size))} + ${esc(fmtDate(e.mtime))} + `; + }).join(''); + + return ` + + + + +${esc(TITLE)} + + + +
+

${esc(TITLE)}

+Se déconnecter +
+
+ + +${rows} +
NomTailleModifié
+
Les liens ci-dessus pointent directement vers les fichiers/dossiers non protégés.
+
+ +`; +} + +export default async function indexRoutes(fastify) { + fastify.get('/', async (req, reply) => { + reply.header('Content-Type', 'text/html; charset=utf-8'); + if (req.query?.logout != null) { + req.session.delete(); + return reply.redirect('/'); + } + if (!req.session.get('auth_ok')) { + return loginPage(); + } + return listingPage(); + }); + + fastify.post('/', async (req, reply) => { + reply.header('Content-Type', 'text/html; charset=utf-8'); + const submitted = req.body?.password ?? ''; + if (timingSafeEqual(submitted, PASSWORD)) { + req.session.set('auth_ok', true); + return reply.redirect('/'); + } + return loginPage('Mot de passe incorrect.'); + }); +} + +function timingSafeEqual(a, b) { + if (typeof a !== 'string' || typeof b !== 'string') return false; + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} diff --git a/routes/search.js b/routes/search.js new file mode 100644 index 0000000..e046218 --- /dev/null +++ b/routes/search.js @@ -0,0 +1,144 @@ +// HTML search view — replaces search.php (the public, human-facing version). + +import { readFile } from 'node:fs/promises'; +import { entryPath } from '../lib/paths.js'; +import { getRatings, lookupRating } from '../lib/imdbRatings.js'; +import { parseQuery } from '../lib/queryParser.js'; +import { filterAndLower } from '../lib/titleFilter.js'; +import { search as runSearch } from '../lib/searchEngine.js'; +import { formatCurrency, formatRuntime, pad2 } from '../lib/format.js'; +import { + POSTER_URL, NO_POSTER_URL, MOVIE_URL, TV_URL, IMDB_URL, +} from '../config.js'; + +function esc(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function center(msg) { + return `
${esc(msg)}
`; +} + +async function getDetail(type, id) { + try { + return JSON.parse(await readFile(entryPath(type, id), 'utf8')); + } catch { + return null; + } +} + +async function render(query) { + if (!query) return ''; + + const parsed = parseQuery(query); + if (!parsed) return ''; + if (parsed.error) return center(parsed.error); + + const { type, titlein, yearin, episodein } = parsed; + const filteredTitleIn = filterAndLower(titlein); + + const matches = await runSearch(type, filteredTitleIn, yearin); + if (!matches.length) { + return center('Not found in localized and original titles database'); + } + + const ratings = await getRatings(); + const movietvurl = type === 'movie' ? MOVIE_URL : TV_URL; + + let html = '
'; + + for (const m of matches) { + const detail = await getDetail(type, m.tmdb); + if (!detail) continue; + + const poster = detail.poster_path; + const src = poster ? `${POSTER_URL}/${poster}` : NO_POSTER_URL; + + let genres = ''; + if (Array.isArray(detail.genres)) { + for (const g of detail.genres) genres += `${g.name} `; + } + + let countries = ''; + if (Array.isArray(detail.production_countries)) { + for (const c of detail.production_countries) countries += `${c.iso_3166_1} `; + } + + const runtime = detail.runtime; + const imdb = !episodein ? detail.imdb_id : detail?.external_ids?.imdb_id; + const { rating: ivote, votes: ivoteCount } = lookupRating(ratings, imdb); + const tvote = Math.round((parseFloat(detail.vote_average) || 0) * 10) / 10; + const tvoteCount = parseInt(detail.vote_count, 10) || 0; + const budget = detail.budget; + const revenue = detail.revenue; + + let seasons; + if (episodein && Array.isArray(detail.seasons)) { + seasons = detail.seasons.map((s) => `S${pad2(s.season_number || 0)}E${pad2(s.episode_count || 0)}`); + } + + html += ''; + html += ``; + html += '
'; + + html += '

'; + if (m.filteredTitle) html += `FR ${esc(m.title)} ${esc(m.year)}
`; + if (m.filteredEnglishTitle) html += `EN ${esc(m.englishTitle)} ${esc(m.year)}
`; + if (m.filteredOriginalTitle) html += `VO ${esc(m.originalTitle)} ${esc(m.year)}
`; + + html += '

'; + if (genres) html += esc(genres); + if (countries) html += `${esc(countries)}`; + if (runtime) html += formatRuntime(runtime); + html += '

'; + + html += '

'; + if (imdb) { + html += ``; + html += ` IMDb  ${esc(imdb)} ${esc(ivote)} ${esc(ivoteCount)} `; + } + html += ``; + html += ` TMDb  ${esc(m.tmdb)} ${esc(tvote)} ${esc(tvoteCount)}
`; + + if (budget || revenue) { + html += `${esc(formatCurrency(budget))} ${esc(formatCurrency(revenue))}`; + } + html += '

'; + + html += '

'; + if (seasons) { + html += 'Episodes finaux '; + for (const s of seasons) html += `${esc(s)} `; + } + html += '

'; + + html += '

'; + html += `${esc(detail.tagline || '')}`; + html += '

'; + + html += '

'; + if (detail.overview) html += esc(detail.overview); + html += '

'; + + html += '
'; + } + + html += '
'; + return html; +} + +async function handle(req, reply) { + reply.header('Content-Type', 'text/html; charset=utf-8'); + return render(req.query?.query || ''); +} + +export default async function searchRoutes(fastify) { + fastify.get('/search', handle); + fastify.get('/search.php', handle); +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..deebb0b --- /dev/null +++ b/server.js @@ -0,0 +1,60 @@ +import Fastify from 'fastify'; +import formbody from '@fastify/formbody'; +import secureSession from '@fastify/secure-session'; +import fastifyStatic from '@fastify/static'; +import { join } from 'node:path'; +import { ROOT, PORT, HOST, SESSION_SECRET } from './config.js'; +import indexRoutes from './routes/index.js'; +import apiRoutes from './routes/api.js'; +import searchRoutes from './routes/search.js'; +import { getRatings } from './lib/imdbRatings.js'; +import { getPool } from './lib/searchEngine.js'; + +const fastify = Fastify({ logger: true, trustProxy: true }); + +await fastify.register(formbody); + +await fastify.register(secureSession, { + // 32 bytes minimum. Use SESSION_SECRET env var in production. + secret: SESSION_SECRET.padEnd(32, '0').slice(0, 32), + salt: 'proxytmdb-salt-1', + cookieName: 'session', + cookie: { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: 'auto', + }, +}); + +await fastify.register(indexRoutes); +await fastify.register(apiRoutes); +await fastify.register(searchRoutes); + +// Serve any other path as a static file from the project root, so that the +// "directory listing" links keep working exactly as they did under Apache. +await fastify.register(fastifyStatic, { + root: ROOT, + serve: true, + index: false, + list: false, + decorateReply: false, + prefix: '/', +}); + +// Warm up: load IMDb ratings and spawn search workers eagerly. +fastify.ready().then(async () => { + try { + await getRatings(); + getPool('movie'); + getPool('tv'); + fastify.log.info('Warmup complete'); + } catch (err) { + fastify.log.warn({ err }, 'Warmup failed'); + } +}); + +fastify.listen({ port: PORT, host: HOST }).catch((err) => { + fastify.log.error(err); + process.exit(1); +}); diff --git a/test/helpers.test.js b/test/helpers.test.js new file mode 100644 index 0000000..9fbeaa9 --- /dev/null +++ b/test/helpers.test.js @@ -0,0 +1,92 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mbLevenshtein, mbStrlen } from '../lib/mbLevenshtein.js'; +import { filterTitle, filterAndLower, translit } from '../lib/titleFilter.js'; +import { parseQuery } from '../lib/queryParser.js'; +import { bucket, entryPath } from '../lib/paths.js'; + +test('mbLevenshtein basic', () => { + assert.equal(mbLevenshtein('kitten', 'sitting'), 3); + assert.equal(mbLevenshtein('', 'abc'), 3); + assert.equal(mbLevenshtein('abc', ''), 3); + assert.equal(mbLevenshtein('abc', 'abc'), 0); +}); + +test('mbLevenshtein utf-8', () => { + // PHP mb_levenshtein remaps multibyte chars; JS works on code points. + // The distance for équal chars should be 0. + assert.equal(mbLevenshtein('café', 'café'), 0); + assert.equal(mbLevenshtein('été', 'été'), 0); + assert.equal(mbLevenshtein('é', 'e'), 1); +}); + +test('mbLevenshtein custom costs', () => { + // INS=10 REP=12 DEL=10 mirrors searchmultithreads.php + assert.equal(mbLevenshtein('abc', 'abcd', 10, 12, 10), 10); + assert.equal(mbLevenshtein('abcd', 'abc', 10, 12, 10), 10); + assert.equal(mbLevenshtein('abc', 'abd', 10, 12, 10), 12); +}); + +test('mbStrlen counts code points', () => { + assert.equal(mbStrlen('abc'), 3); + assert.equal(mbStrlen('café'), 4); + assert.equal(mbStrlen('œuf'), 3); +}); + +test('translit replaces ligatures and superscripts', () => { + assert.equal(translit('Œuf'), 'Oeuf'); + assert.equal(translit('cœur'), 'coeur'); + assert.equal(translit('m²'), 'm2'); +}); + +test('filterTitle strips non-Latin/digit', () => { + assert.equal(filterTitle('Hello, World!'), 'HelloWorld'); + assert.equal(filterTitle('Inception (2010)'), 'Inception2010'); + assert.equal(filterTitle('北京'), ''); + assert.equal(filterTitle('Café au lait'), 'Caféaulait'); +}); + +test('filterAndLower', () => { + assert.equal(filterAndLower('Mr. Robot S01E02'), 'mrrobots01e02'); + assert.equal(filterAndLower('Cœur de Pirate'), 'coeurdepirate'); +}); + +test('parseQuery: movie with year', () => { + const r = parseQuery('Inception 2010 1080p BluRay'); + assert.equal(r.type, 'movie'); + assert.equal(r.yearin, 2010); + assert.equal(r.episodein, ''); + assert.equal(r.titlein.startsWith('Inception '), true); +}); + +test('parseQuery: 1080 is not a year', () => { + const r = parseQuery('Inception 2010 1080p'); + assert.equal(r.yearin, 2010); +}); + +test('parseQuery: tv with episode', () => { + const r = parseQuery('Mr.Robot.S01E02.FRENCH.1080p'); + assert.equal(r.type, 'tv'); + assert.equal(r.episodein, 'S01E02'); + assert.equal(r.titlein.startsWith('Mr.Robot'), true); +}); + +test('parseQuery: tv with year + episode', () => { + const r = parseQuery('Some.Show.2015.S02E10'); + assert.equal(r.type, 'tv'); + assert.equal(r.yearin, 2015); + assert.equal(r.episodein, 'S02E10'); +}); + +test('parseQuery: no year no episode', () => { + const r = parseQuery('Just a title'); + assert.ok(r.error); +}); + +test('paths bucket + entryPath', () => { + assert.equal(bucket(100), '0'); + assert.equal(bucket(1500), '1'); + assert.equal(bucket(1675803), '1675'); + assert.ok(entryPath('movie', 100).endsWith('/movie/0/100.json')); + assert.ok(entryPath('tv', 1408).endsWith('/tv/1/1408.json')); +});