Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome

This commit is contained in:
unfr
2026-04-24 07:35:10 +02:00
parent f9745a2390
commit a184a21f57
36 changed files with 2060 additions and 364 deletions

View File

@@ -1,6 +1,8 @@
# --- Secrets / runtime (obligatoires) --- # --- Secrets / runtime (obligatoires) ---
TMDB_API_KEY=your_tmdb_api_key_here TMDB_API_KEY=your_tmdb_api_key_here
PROXYTMDB_PASSWORD=change_me # Hash argon2id du mot de passe admin. Genere avec :
# node tools/hashPassword.js 'mon-mot-de-passe'
ADMIN_PASSWORD_HASH=$argon2id$v=19$m=19456,t=2,p=1$...
# 32 caracteres. Generer avec : # 32 caracteres. Generer avec :
# node -e "console.log(require('crypto').randomBytes(32).toString('base64').slice(0,32))" # node -e "console.log(require('crypto').randomBytes(32).toString('base64').slice(0,32))"
SESSION_SECRET=change_me_to_a_random_32_chars_str SESSION_SECRET=change_me_to_a_random_32_chars_str
@@ -9,6 +11,8 @@ SESSION_SECRET=change_me_to_a_random_32_chars_str
PORT=3000 PORT=3000
HOST=0.0.0.0 HOST=0.0.0.0
PAGE_TITLE=Index protégé PAGE_TITLE=Index protégé
# Plafond global de requetes par seconde par IP
RATE_LIMIT_PER_SEC=50
# --- URLs externes (laisse les defauts sauf si tu changes de domaine) --- # --- URLs externes (laisse les defauts sauf si tu changes de domaine) ---
#TMDB_API_BASE=https://api.themoviedb.org/3 #TMDB_API_BASE=https://api.themoviedb.org/3

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@ npm-debug.log*
# Logs runtime # Logs runtime
cron.txt cron.txt
lastcron.txt lastcron.txt
.cron.lock
*.log *.log
# Donnees generees (telechargees / construites par le cron) # Donnees generees (telechargees / construites par le cron)

View File

@@ -128,35 +128,77 @@ Réponse JSON :
L'ancien chemin `/api.php` est aussi exposé pour compatibilité. L'ancien chemin `/api.php` est aussi exposé pour compatibilité.
### `GET /search?query=<requête>` Réponses cachées en mémoire (LRU 1000 entrées, TTL 1h). Reload automatique
Mêmes données que `/api?t=search` mais rendues en HTML (vignettes posters, des chunks de recherche après chaque cron (watcher `fs.watch`).
panneau d'info, liens IMDb/TMDb cliquables). Compat : `/search.php`.
### `GET /` ### `GET /api?t=imdb&q=<imdb_id>`
Page d'index protégée par mot de passe + listing des fichiers du projet. Lookup direct par IMDb ID (`tt0133093`). Renvoie le détail movie ou tv
correspondant, avec note IMDb fusionnée. Utilise les mappings
`imdb2movie.json` / `imdb2tv.json` chargés en mémoire.
### `GET /api?t=providers&type=movie|tv&q=<id>`
Watch providers JustWatch par pays (FR, US, etc.) — données déjà téléchargées
par le cron, exposées sous le format TMDB original.
### `GET /` — Interface web
SPA vanilla JS (zéro build), thème sombre. Une barre de recherche, grille de
posters, modal de détail avec tagline/overview/budget/revenue/providers FR.
Accepte requêtes texte (`Inception 2010`), IMDb IDs (`tt0133093`) et URLs
TMDB collées (`themoviedb.org/movie/27205`).
### `GET /admin`
Listing protégé des fichiers du projet (mot de passe argon2id).
Pour générer un nouveau hash :
```bash
node tools/hashPassword.js 'mon-mot-de-passe'
```
Puis copier la sortie dans `.env` sous `ADMIN_PASSWORD_HASH=`.
### `GET /health`
JSON liveness/readiness : status, uptime, mémoire, nombre de notes IMDb
chargées. Renvoie 503 si l'index IMDb n'a pas pu être chargé.
### `GET /metrics`
Format Prometheus standard : `http_requests_total`, `http_request_duration_seconds`,
`search_cache_hits_total`, `search_cache_misses_total`, `imdb_ratings_total`,
`search_workers`, plus métriques process par défaut (CPU, RSS, event loop).
### Rate limit
50 requêtes/seconde par IP par défaut (configurable via `RATE_LIMIT_PER_SEC`).
`/health` et `/metrics` exemptés.
## Architecture ## Architecture
``` ```
proxytmdb/ proxytmdb/
├── server.js # Bootstrap Fastify ├── server.js # Bootstrap Fastify + rate limit + sessions
├── config.js # Constantes, ports, chemins ├── config.js # Constantes, ports, chemins, env vars
├── biome.json # Lint + format
├── public/ # SPA vanilla JS (UI publique)
│ ├── index.html
│ ├── style.css
│ └── app.js
├── lib/ ├── lib/
│ ├── paths.js # Layout disque <type>/<floor(id/1000)>/<id>.json │ ├── paths.js # Layout <type>/<floor(id/1000)>/<id>.json
│ ├── mbLevenshtein.js # Levenshtein UTF-8 par codepoint │ ├── mbLevenshtein.js # Levenshtein UTF-8 par codepoint
│ ├── titleFilter.js # Translit ligatures + filtre Latin/chiffres │ ├── titleFilter.js # Translit ligatures + filtre Latin/chiffres
│ ├── queryParser.js # Extraction annee/episode/titre │ ├── queryParser.js # Extraction annee/episode/titre
│ ├── imdbRatings.js # Index IMDb en memoire (Map) │ ├── imdbRatings.js # Index IMDb en memoire (Map)
│ ├── imdbMapping.js # Mapping IMDb -> TMDb id
│ ├── http.js # fetch + retry + concurrence limitee │ ├── http.js # fetch + retry + concurrence limitee
│ ├── format.js # Devises et runtime │ ├── format.js # Devises et runtime
│ ├── searchEngine.js # Pool de 8 workers persistants │ ├── lockFile.js # Lock file PID-based pour le cron
│ ├── password.js # Argon2id hash + verify
│ ├── metrics.js # Counters/Histograms Prometheus
│ ├── searchEngine.js # Pool workers + watcher reload chaud
│ └── searchWorker.js # Worker thread (1 chunk de la base) │ └── searchWorker.js # Worker thread (1 chunk de la base)
├── routes/ ├── routes/
│ ├── index.js # Login + listing protege │ ├── api.js # /api (movie/tv/imdb/providers/search) + cache LRU
│ ├── api.js # Endpoints JSON │ ├── admin.js # /admin (login argon2 + listing)
│ └── search.js # Vue HTML │ └── health.js # /health + /metrics
├── cron/ ├── cron/
│ ├── runAll.js # Pipeline complet │ ├── runAll.js # Pipeline complet (avec lock file)
│ ├── run.sh # Wrapper nvm pour crontab
│ ├── imdbRatings.js # title.ratings.tsv.gz │ ├── imdbRatings.js # title.ratings.tsv.gz
│ ├── tmdbExports.js # Exports quotidiens TMDB │ ├── tmdbExports.js # Exports quotidiens TMDB
│ ├── tmdbSync.js # Sync incrementale via /changes │ ├── tmdbSync.js # Sync incrementale via /changes
@@ -164,12 +206,24 @@ proxytmdb/
│ ├── tmdb2imdb.js # Mappings bidirectionnels │ ├── tmdb2imdb.js # Mappings bidirectionnels
│ ├── buildSearch.js # Chunks de recherche │ ├── buildSearch.js # Chunks de recherche
│ └── ambiguity.js # Detection des doublons │ └── ambiguity.js # Detection des doublons
├── tools/
│ └── hashPassword.js # Outil CLI pour generer un hash argon2
├── test/ ├── test/
│ └── helpers.test.js # Tests unitaires │ └── helpers.test.js # Tests unitaires
├── tmdbintegral/ # Donnees (gitignore) ├── tmdbintegral/ # Donnees (gitignore)
└── imdbratings.tsv # Donnees IMDb (gitignore) └── imdbratings.tsv # Donnees IMDb (gitignore)
``` ```
## Qualité de code
Lint + format avec **Biome** (config dans `biome.json`) :
```bash
npm run lint # Verifie sans modifier
npm run format # Formate uniquement (write)
npm run fix # Lint + format + auto-fix
```
## Tests ## Tests
```bash ```bash

44
biome.json Normal file
View File

@@ -0,0 +1,44 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.13/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": {
"includes": [
"**/*.js",
"**/*.json",
"**/*.css",
"**/*.html",
"!!**/node_modules",
"!!**/tmdbintegral",
"!!**/imdbratings.tsv",
"!!**/package-lock.json"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 110,
"lineEnding": "lf"
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "all",
"semicolons": "always",
"arrowParentheses": "always"
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"useTemplate": "warn",
"useConst": "warn"
},
"suspicious": {
"noConsole": "off"
}
}
}
}

View File

@@ -1,5 +1,5 @@
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -30,10 +30,11 @@ function str(name, def) {
// Secrets / runtime // Secrets / runtime
export const TMDB_API_KEY = required('TMDB_API_KEY'); export const TMDB_API_KEY = required('TMDB_API_KEY');
export const PASSWORD = required('PROXYTMDB_PASSWORD'); export const ADMIN_PASSWORD_HASH = required('ADMIN_PASSWORD_HASH');
export const SESSION_SECRET = required('SESSION_SECRET'); export const SESSION_SECRET = required('SESSION_SECRET');
export const PORT = int('PORT', 3000); export const PORT = int('PORT', 3000);
export const HOST = str('HOST', '0.0.0.0'); export const HOST = str('HOST', '0.0.0.0');
export const RATE_LIMIT_PER_SEC = int('RATE_LIMIT_PER_SEC', 50);
// URLs externes // URLs externes
export const TMDB_API_BASE = str('TMDB_API_BASE', 'https://api.themoviedb.org/3'); export const TMDB_API_BASE = str('TMDB_API_BASE', 'https://api.themoviedb.org/3');

View File

@@ -5,7 +5,7 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { writeFile } from 'node:fs/promises'; import { writeFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { TMDBINTEGRAL_DIR, NB_SEARCH_PARTS } from '../config.js'; import { NB_SEARCH_PARTS, TMDBINTEGRAL_DIR } from '../config.js';
const TMDB = 0; const TMDB = 0;
const FILTEREDTITLE = 4; const FILTEREDTITLE = 4;
@@ -32,9 +32,24 @@ export async function buildAmbiguity(type, nbParts = NB_SEARCH_PARTS) {
const fr = db[FILTEREDTITLE]; const fr = db[FILTEREDTITLE];
const en = db[FILTEREDENGLISHTITLE]; const en = db[FILTEREDENGLISHTITLE];
const vo = db[FILTEREDORIGINALTITLE]; const vo = db[FILTEREDORIGINALTITLE];
if (fr) { tmdbs.push(db[TMDB]); filteredTitles.push(fr); years.push(db[YEAR][0]); languages.push('FR'); } if (fr) {
if (en) { tmdbs.push(db[TMDB]); filteredTitles.push(en); years.push(db[YEAR][0]); languages.push('EN'); } tmdbs.push(db[TMDB]);
if (vo) { tmdbs.push(db[TMDB]); filteredTitles.push(vo); years.push(db[YEAR][0]); languages.push('VO'); } 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) // PHP: array_multisort(filteredtitles, years, tmdbs, languages)
@@ -87,7 +102,7 @@ export async function buildAmbiguity(type, nbParts = NB_SEARCH_PARTS) {
} }
flush(); flush();
await writeFile(out, lines.length ? lines.join('\n') + '\n' : ''); await writeFile(out, lines.length ? `${lines.join('\n')}\n` : '');
} }
if (import.meta.url === `file://${process.argv[1]}`) { if (import.meta.url === `file://${process.argv[1]}`) {

View File

@@ -9,21 +9,23 @@
import { createReadStream, existsSync, readFileSync } from 'node:fs'; import { createReadStream, existsSync, readFileSync } from 'node:fs';
import { writeFile } from 'node:fs/promises'; import { writeFile } from 'node:fs/promises';
import { createInterface } from 'node:readline';
import { join } from 'node:path'; import { join } from 'node:path';
import { TMDBINTEGRAL_DIR, NB_SEARCH_PARTS } from '../config.js'; import { createInterface } from 'node:readline';
import { NB_SEARCH_PARTS, TMDBINTEGRAL_DIR } from '../config.js';
import { mbStrlen } from '../lib/mbLevenshtein.js';
import { entryPath } from '../lib/paths.js'; import { entryPath } from '../lib/paths.js';
import { filterTitle } from '../lib/titleFilter.js'; import { filterTitle } from '../lib/titleFilter.js';
import { mbStrlen } from '../lib/mbLevenshtein.js';
function lower(s) { return s.toLocaleLowerCase(); } function lower(s) {
return s.toLocaleLowerCase();
}
function extractEnglishTitle(detail, type) { function extractEnglishTitle(detail, type) {
const tr = detail?.translations?.translations; const tr = detail?.translations?.translations;
if (!Array.isArray(tr)) return ''; if (!Array.isArray(tr)) return '';
for (const t of tr) { for (const t of tr) {
if (t.iso_639_1 === 'en') { if (t.iso_639_1 === 'en') {
return type === 'movie' ? (t.data?.title || '') : (t.data?.name || ''); return type === 'movie' ? t.data?.title || '' : t.data?.name || '';
} }
} }
return ''; return '';
@@ -73,20 +75,13 @@ function buildEntry(masterObj, detail, type) {
const seen = new Set(); const seen = new Set();
const uniqYears = []; const uniqYears = [];
for (const y of years) { for (const y of years) {
if (!seen.has(y)) { seen.add(y); uniqYears.push(y); } if (!seen.has(y)) {
seen.add(y);
uniqYears.push(y);
}
} }
return [ return [tmdb, title, englishTitle, originalTitle, lower(ft), lower(fe), lower(fo), uniqYears, popularity];
tmdb,
title,
englishTitle,
originalTitle,
lower(ft),
lower(fe),
lower(fo),
uniqYears,
popularity,
];
} }
export async function buildSearch(type, nbParts = NB_SEARCH_PARTS) { export async function buildSearch(type, nbParts = NB_SEARCH_PARTS) {
@@ -99,11 +94,19 @@ export async function buildSearch(type, nbParts = NB_SEARCH_PARTS) {
for await (const line of rl) { for await (const line of rl) {
if (!line) continue; if (!line) continue;
let masterObj; let masterObj;
try { masterObj = JSON.parse(line); } catch { continue; } try {
masterObj = JSON.parse(line);
} catch {
continue;
}
const path = entryPath(type, masterObj.id); const path = entryPath(type, masterObj.id);
if (!existsSync(path)) continue; if (!existsSync(path)) continue;
let detail; let detail;
try { detail = JSON.parse(readFileSync(path, 'utf8')); } catch { continue; } try {
detail = JSON.parse(readFileSync(path, 'utf8'));
} catch {
continue;
}
const entry = buildEntry(masterObj, detail, type); const entry = buildEntry(masterObj, detail, type);
if (entry) database.push(entry); if (entry) database.push(entry);
} }

View File

@@ -1,10 +1,10 @@
import { createWriteStream } from 'node:fs'; import { createWriteStream } from 'node:fs';
import { rename } from 'node:fs/promises'; import { rename } from 'node:fs/promises';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises'; import { pipeline } from 'node:stream/promises';
import { createGunzip } from 'node:zlib'; import { createGunzip } from 'node:zlib';
import { Readable } from 'node:stream'; import { IMDB_DATASETS_BASE, IMDB_RATINGS, ROOT } from '../config.js';
import { join } from 'node:path';
import { ROOT, IMDB_DATASETS_BASE, IMDB_RATINGS } from '../config.js';
const FILE = 'title.ratings.tsv'; const FILE = 'title.ratings.tsv';
@@ -18,11 +18,7 @@ export async function syncImdbRatings() {
throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
} }
await pipeline( await pipeline(Readable.fromWeb(res.body), createGunzip(), createWriteStream(tmpPath));
Readable.fromWeb(res.body),
createGunzip(),
createWriteStream(tmpPath),
);
await rename(tmpPath, IMDB_RATINGS); await rename(tmpPath, IMDB_RATINGS);
console.log(`Wrote ${IMDB_RATINGS}`); console.log(`Wrote ${IMDB_RATINGS}`);

View File

@@ -2,13 +2,17 @@
import { createReadStream, existsSync, readdirSync, unlinkSync } from 'node:fs'; import { createReadStream, existsSync, readdirSync, unlinkSync } from 'node:fs';
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import { createInterface } from 'node:readline';
import { join } from 'node:path'; import { join } from 'node:path';
import { createInterface } from 'node:readline';
import { import {
TMDBINTEGRAL_DIR, JUSTWATCH_MOVIE_DIR, JUSTWATCH_TV_DIR, TMDB_API_KEY, TMDB_API_BASE, JUSTWATCH_MOVIE_DIR,
JUSTWATCH_TV_DIR,
TMDB_API_BASE,
TMDB_API_KEY,
TMDBINTEGRAL_DIR,
} from '../config.js'; } from '../config.js';
import { Limiter } from '../lib/http.js'; import { Limiter } from '../lib/http.js';
import { justwatchDir, justwatchPath, bucket } from '../lib/paths.js'; import { bucket, justwatchDir, justwatchPath } from '../lib/paths.js';
const DOWNLOAD_CONCURRENCY = 16; const DOWNLOAD_CONCURRENCY = 16;
@@ -22,7 +26,9 @@ async function readMasterIds(type) {
try { try {
const obj = JSON.parse(line); const obj = JSON.parse(line);
if (typeof obj.id === 'number') ids.push(obj.id); if (typeof obj.id === 'number') ids.push(obj.id);
} catch { /* ignore */ } } catch {
/* ignore */
}
} }
return ids; return ids;
} }
@@ -50,10 +56,18 @@ function removeOrphans(type, ids) {
const baseDir = type === 'movie' ? JUSTWATCH_MOVIE_DIR : JUSTWATCH_TV_DIR; const baseDir = type === 'movie' ? JUSTWATCH_MOVIE_DIR : JUSTWATCH_TV_DIR;
const expected = new Set(ids); const expected = new Set(ids);
let buckets; let buckets;
try { buckets = readdirSync(baseDir); } catch { return; } try {
buckets = readdirSync(baseDir);
} catch {
return;
}
for (const b of buckets) { for (const b of buckets) {
let entries; let entries;
try { entries = readdirSync(join(baseDir, b)); } catch { continue; } try {
entries = readdirSync(join(baseDir, b));
} catch {
continue;
}
for (const fname of entries) { for (const fname of entries) {
if (!fname.endsWith('.json')) continue; if (!fname.endsWith('.json')) continue;
const id = parseInt(fname.slice(0, -5), 10); const id = parseInt(fname.slice(0, -5), 10);
@@ -61,7 +75,11 @@ function removeOrphans(type, ids) {
if (!expected.has(id)) { if (!expected.has(id)) {
const p = join(baseDir, b, fname); const p = join(baseDir, b, fname);
console.log(`Removing: "justwatch${type}/${b}/${fname}"`); console.log(`Removing: "justwatch${type}/${b}/${fname}"`);
try { unlinkSync(p); } catch { /* ignore */ } try {
unlinkSync(p);
} catch {
/* ignore */
}
} }
} }
} }

View File

@@ -9,44 +9,36 @@
// //
// Writes cron.txt at start/end (mirrors cron.sh). // Writes cron.txt at start/end (mirrors cron.sh).
import { writeFileSync, appendFileSync } from 'node:fs'; import { appendFileSync, writeFileSync } from 'node:fs';
import { CRON_TXT } from '../config.js'; import { join } from 'node:path';
import { CRON_TXT, ROOT } from '../config.js';
import { acquireLock } from '../lib/lockFile.js';
import { buildAmbiguity } from './ambiguity.js';
import { buildSearch } from './buildSearch.js';
import { syncImdbRatings } from './imdbRatings.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 { syncType as syncJustwatch } from './justwatchSync.js';
import { buildMapping } from './tmdb2imdb.js'; import { buildMapping } from './tmdb2imdb.js';
import { buildSearch } from './buildSearch.js'; import { syncExports } from './tmdbExports.js';
import { buildAmbiguity } from './ambiguity.js'; import { syncType as syncTmdb } from './tmdbSync.js';
const LOCK_PATH = join(ROOT, '.cron.lock');
function dateStamp() { function dateStamp() {
return new Date().toString(); return new Date().toString();
} }
export async function runAll() { export async function runAll() {
acquireLock(LOCK_PATH);
writeFileSync(CRON_TXT, `Started At ${dateStamp()}\n`); writeFileSync(CRON_TXT, `Started At ${dateStamp()}\n`);
await syncImdbRatings(); await syncImdbRatings();
await syncExports(); await syncExports();
await Promise.all([ await Promise.all([syncTmdb('movie'), syncTmdb('tv'), syncJustwatch('movie'), syncJustwatch('tv')]);
syncTmdb('movie'),
syncTmdb('tv'),
syncJustwatch('movie'),
syncJustwatch('tv'),
]);
await Promise.all([ await Promise.all([buildMapping('movie'), buildMapping('tv'), buildSearch('movie'), buildSearch('tv')]);
buildMapping('movie'),
buildMapping('tv'),
buildSearch('movie'),
buildSearch('tv'),
]);
await Promise.all([ await Promise.all([buildAmbiguity('movie'), buildAmbiguity('tv')]);
buildAmbiguity('movie'),
buildAmbiguity('tv'),
]);
appendFileSync(CRON_TXT, `Finished At ${dateStamp()}\n`); appendFileSync(CRON_TXT, `Finished At ${dateStamp()}\n`);
} }

View File

@@ -3,8 +3,8 @@
import { createReadStream, existsSync, readFileSync } from 'node:fs'; import { createReadStream, existsSync, readFileSync } from 'node:fs';
import { writeFile } from 'node:fs/promises'; import { writeFile } from 'node:fs/promises';
import { createInterface } from 'node:readline';
import { join } from 'node:path'; import { join } from 'node:path';
import { createInterface } from 'node:readline';
import { TMDBINTEGRAL_DIR } from '../config.js'; import { TMDBINTEGRAL_DIR } from '../config.js';
import { entryPath } from '../lib/paths.js'; import { entryPath } from '../lib/paths.js';
@@ -22,12 +22,20 @@ export async function buildMapping(type) {
for await (const line of rl) { for await (const line of rl) {
if (!line) continue; if (!line) continue;
let obj; let obj;
try { obj = JSON.parse(line); } catch { continue; } try {
obj = JSON.parse(line);
} catch {
continue;
}
const tmdb = obj.id; const tmdb = obj.id;
const path = entryPath(type, tmdb); const path = entryPath(type, tmdb);
if (!existsSync(path)) continue; if (!existsSync(path)) continue;
let detail; let detail;
try { detail = JSON.parse(readFileSync(path, 'utf8')); } catch { continue; } try {
detail = JSON.parse(readFileSync(path, 'utf8'));
} catch {
continue;
}
const imdb = detail?.external_ids?.imdb_id; const imdb = detail?.external_ids?.imdb_id;
if (imdb) { if (imdb) {
data1[tmdb] = imdb; data1[tmdb] = imdb;

View File

@@ -1,10 +1,10 @@
import { createWriteStream } from 'node:fs'; import { createWriteStream } from 'node:fs';
import { rename, mkdir } from 'node:fs/promises'; import { mkdir, rename } from 'node:fs/promises';
import { join } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises'; import { pipeline } from 'node:stream/promises';
import { createGunzip } from 'node:zlib'; import { createGunzip } from 'node:zlib';
import { Readable } from 'node:stream'; import { TMDB_EXPORTS_BASE, TMDBINTEGRAL_DIR } from '../config.js';
import { join } from 'node:path';
import { TMDBINTEGRAL_DIR, TMDB_EXPORTS_BASE } from '../config.js';
function formatMMDDYYYY(date) { function formatMMDDYYYY(date) {
const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); const mm = String(date.getUTCMonth() + 1).padStart(2, '0');

View File

@@ -7,15 +7,13 @@
// 3. Walk through every numeric id < max(tmdbs) and remove orphan files that // 3. Walk through every numeric id < max(tmdbs) and remove orphan files that
// no longer appear in the master list. // no longer appear in the master list.
import { createReadStream, createWriteStream, existsSync, statSync, readdirSync, unlinkSync } from 'node:fs'; import { createReadStream, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs';
import { mkdir, stat, writeFile, unlink } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import { createInterface } from 'node:readline';
import { join } from 'node:path'; import { join } from 'node:path';
import { import { createInterface } from 'node:readline';
TMDBINTEGRAL_DIR, MOVIE_DIR, TV_DIR, TMDB_API_KEY, TMDB_API_BASE, CHANGES_DAYS, import { CHANGES_DAYS, MOVIE_DIR, TMDB_API_BASE, TMDB_API_KEY, TMDBINTEGRAL_DIR, TV_DIR } from '../config.js';
} from '../config.js';
import { fetchJson, Limiter } from '../lib/http.js'; import { fetchJson, Limiter } from '../lib/http.js';
import { entryDir, entryPath, bucket } from '../lib/paths.js'; import { bucket, entryDir, entryPath } from '../lib/paths.js';
const CHANGES_SECS = CHANGES_DAYS * 24 * 3600; const CHANGES_SECS = CHANGES_DAYS * 24 * 3600;
const DOWNLOAD_CONCURRENCY = 16; const DOWNLOAD_CONCURRENCY = 16;
@@ -63,7 +61,11 @@ async function findChanges(type) {
const path = entryPath(type, id); const path = entryPath(type, id);
if (!existsSync(path)) continue; if (!existsSync(path)) continue;
let st; let st;
try { st = statSync(path); } catch { continue; } try {
st = statSync(path);
} catch {
continue;
}
// PHP uses filectime; on Linux ctime tracks metadata changes too, but the // 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 // intent is "last time the local file was refreshed". We use mtime which
// is closer to that intent in JS (writeFile updates mtime). // is closer to that intent in JS (writeFile updates mtime).
@@ -72,7 +74,9 @@ async function findChanges(type) {
const days = Math.floor(ageSecs / 86400); const days = Math.floor(ageSecs / 86400);
const hours = Math.floor((ageSecs % 86400) / 3600); const hours = Math.floor((ageSecs % 86400) / 3600);
const minutes = Math.floor((ageSecs % 3600) / 60); const minutes = Math.floor((ageSecs % 3600) / 60);
console.log(`Updating: "${type}/${bucket(id)}/${id}.json" ${days} days, ${hours} hours, ${minutes} minutes`); console.log(
`Updating: "${type}/${bucket(id)}/${id}.json" ${days} days, ${hours} hours, ${minutes} minutes`,
);
updates.add(id); updates.add(id);
} }
} }
@@ -90,7 +94,9 @@ async function readMasterIds(type) {
try { try {
const obj = JSON.parse(line); const obj = JSON.parse(line);
if (typeof obj.id === 'number') ids.push(obj.id); if (typeof obj.id === 'number') ids.push(obj.id);
} catch { /* ignore malformed lines */ } } catch {
/* ignore malformed lines */
}
} }
return ids; return ids;
} }
@@ -121,10 +127,18 @@ function removeOrphans(type, sortedIds) {
const baseDir = type === 'movie' ? MOVIE_DIR : TV_DIR; const baseDir = type === 'movie' ? MOVIE_DIR : TV_DIR;
const expected = new Set(sortedIds); const expected = new Set(sortedIds);
let buckets; let buckets;
try { buckets = readdirSync(baseDir); } catch { return; } try {
buckets = readdirSync(baseDir);
} catch {
return;
}
for (const b of buckets) { for (const b of buckets) {
let entries; let entries;
try { entries = readdirSync(join(baseDir, b)); } catch { continue; } try {
entries = readdirSync(join(baseDir, b));
} catch {
continue;
}
for (const fname of entries) { for (const fname of entries) {
if (!fname.endsWith('.json')) continue; if (!fname.endsWith('.json')) continue;
const id = parseInt(fname.slice(0, -5), 10); const id = parseInt(fname.slice(0, -5), 10);
@@ -132,7 +146,11 @@ function removeOrphans(type, sortedIds) {
if (!expected.has(id)) { if (!expected.has(id)) {
const p = join(baseDir, b, fname); const p = join(baseDir, b, fname);
console.log(`Removing: "${type}/${b}/${fname}"`); console.log(`Removing: "${type}/${b}/${fname}"`);
try { unlinkSync(p); } catch { /* ignore */ } try {
unlinkSync(p);
} catch {
/* ignore */
}
} }
} }
} }

View File

@@ -52,8 +52,16 @@ export class Limiter {
Promise.resolve() Promise.resolve()
.then(fn) .then(fn)
.then( .then(
(v) => { this.active--; resolve(v); this._next(); }, (v) => {
(e) => { this.active--; reject(e); this._next(); }, this.active--;
resolve(v);
this._next();
},
(e) => {
this.active--;
reject(e);
this._next();
},
); );
}; };
tryRun(); tryRun();

35
lib/imdbMapping.js Normal file
View File

@@ -0,0 +1,35 @@
// Loads imdb2movie.json / imdb2tv.json into memory and reloads on mtime change.
import { readFile, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { TMDBINTEGRAL_DIR } from '../config.js';
const cache = { movie: null, tv: null };
const mtimes = { movie: 0, tv: 0 };
async function loadOne(type) {
const file = join(TMDBINTEGRAL_DIR, `imdb2${type}.json`);
const st = await stat(file);
if (cache[type] && st.mtimeMs === mtimes[type]) return cache[type];
const obj = JSON.parse(await readFile(file, 'utf8'));
cache[type] = new Map(Object.entries(obj).map(([k, v]) => [k, Number(v)]));
mtimes[type] = st.mtimeMs;
return cache[type];
}
export async function lookupImdb(imdbId) {
// Try movie first, then tv (matches PHP behaviour of exhaustive lookup).
try {
const movieMap = await loadOne('movie');
if (movieMap.has(imdbId)) return { type: 'movie', tmdb: movieMap.get(imdbId) };
} catch {
/* file may be missing on a fresh install */
}
try {
const tvMap = await loadOne('tv');
if (tvMap.has(imdbId)) return { type: 'tv', tmdb: tvMap.get(imdbId) };
} catch {
/* same */
}
return null;
}

View File

@@ -11,7 +11,10 @@ export async function loadRatings(filePath = IMDB_RATINGS) {
const rl = createInterface({ input: stream, crlfDelay: Infinity }); const rl = createInterface({ input: stream, crlfDelay: Infinity });
let first = true; let first = true;
for await (const line of rl) { for await (const line of rl) {
if (first) { first = false; continue; } if (first) {
first = false;
continue;
}
if (!line) continue; if (!line) continue;
const tab1 = line.indexOf('\t'); const tab1 = line.indexOf('\t');
if (tab1 < 0) continue; if (tab1 < 0) continue;

60
lib/lockFile.js Normal file
View File

@@ -0,0 +1,60 @@
// Simple PID-based lock file. Atomic via O_EXCL.
// If a stale lock is found (PID no longer alive), it is removed.
import { closeSync, openSync, readFileSync, unlinkSync, writeSync } from 'node:fs';
function isAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (e) {
return e.code === 'EPERM';
}
}
export function acquireLock(lockPath) {
for (let attempt = 0; attempt < 2; attempt++) {
try {
const fd = openSync(lockPath, 'wx');
writeSync(fd, String(process.pid));
closeSync(fd);
const release = () => {
try {
unlinkSync(lockPath);
} catch {
/* ignore */
}
};
process.on('exit', release);
process.on('SIGINT', () => {
release();
process.exit(130);
});
process.on('SIGTERM', () => {
release();
process.exit(143);
});
return release;
} catch (err) {
if (err.code !== 'EEXIST') throw err;
// Lock exists — check if owner is still alive
let pid = 0;
try {
pid = parseInt(readFileSync(lockPath, 'utf8').trim(), 10);
} catch {
/* unreadable */
}
if (pid && isAlive(pid)) {
throw new Error(`Cron already running (PID ${pid}, lock ${lockPath})`);
}
// Stale lock — remove and retry
console.log(`Removing stale lock (PID ${pid || '?'} no longer alive): ${lockPath}`);
try {
unlinkSync(lockPath);
} catch {
/* ignore */
}
}
}
throw new Error(`Could not acquire lock after retries: ${lockPath}`);
}

View File

@@ -21,7 +21,7 @@ export function mbLevenshtein(s1, s2, costIns = 1, costRep = 1, costDel = 1) {
const del = prev[j] + costDel; const del = prev[j] + costDel;
const ins = curr[j - 1] + costIns; const ins = curr[j - 1] + costIns;
const rep = prev[j - 1] + cost; const rep = prev[j - 1] + cost;
curr[j] = del < ins ? (del < rep ? del : rep) : (ins < rep ? ins : rep); curr[j] = del < ins ? (del < rep ? del : rep) : ins < rep ? ins : rep;
} }
[prev, curr] = [curr, prev]; [prev, curr] = [curr, prev];
} }

46
lib/metrics.js Normal file
View File

@@ -0,0 +1,46 @@
// Prometheus metrics. Reusable counters/histograms for the API + cache.
import { Counter, collectDefaultMetrics, Gauge, Histogram, Registry } from 'prom-client';
export const registry = new Registry();
collectDefaultMetrics({ register: registry });
export const httpRequests = new Counter({
name: 'http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'route', 'status'],
registers: [registry],
});
export const httpDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'route', 'status'],
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5],
registers: [registry],
});
export const searchCacheHits = new Counter({
name: 'search_cache_hits_total',
help: 'Number of search results served from the LRU cache',
registers: [registry],
});
export const searchCacheMisses = new Counter({
name: 'search_cache_misses_total',
help: 'Number of search requests that bypassed the cache',
registers: [registry],
});
export const imdbRatingsCount = new Gauge({
name: 'imdb_ratings_total',
help: 'Number of IMDb ratings currently loaded in memory',
registers: [registry],
});
export const searchWorkers = new Gauge({
name: 'search_workers',
help: 'Number of active search workers per type',
labelNames: ['type'],
registers: [registry],
});

21
lib/password.js Normal file
View File

@@ -0,0 +1,21 @@
import { Algorithm, hash, verify } from '@node-rs/argon2';
const OPTS = {
algorithm: Algorithm.Argon2id,
memoryCost: 19456, // 19 MiB (OWASP 2024 recommendation)
timeCost: 2,
parallelism: 1,
};
export async function hashPassword(plain) {
return await hash(plain, OPTS);
}
export async function verifyPassword(stored, plain) {
if (!stored || !plain) return false;
try {
return await verify(stored, plain);
} catch {
return false;
}
}

View File

@@ -1,5 +1,5 @@
import { join } from 'node:path'; import { join } from 'node:path';
import { MOVIE_DIR, TV_DIR, JUSTWATCH_MOVIE_DIR, JUSTWATCH_TV_DIR } from '../config.js'; import { JUSTWATCH_MOVIE_DIR, JUSTWATCH_TV_DIR, MOVIE_DIR, TV_DIR } from '../config.js';
export function bucket(id) { export function bucket(id) {
return String(Math.floor(id / 1000)); return String(Math.floor(id / 1000));

View File

@@ -12,7 +12,8 @@ const YEAR_RE = /(19|20)\d{2}/g;
// - the lowercase 'x' in NxN, and uppercase 'E' in standalone Exxx, are case-sensitive // - 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 // Greedy left-to-right alternation means "S01E02" is consumed whole, so the
// trailing "E02" alternative cannot match inside it. // 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; 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. // PHP uses byte offsets (substr). To stay byte-faithful, work on the UTF-8 bytes.
const utf8 = (s) => Buffer.from(s, 'utf8'); const utf8 = (s) => Buffer.from(s, 'utf8');
@@ -21,8 +22,9 @@ const sliceBytes = (s, end) => utf8(s).slice(0, end).toString('utf8');
function findAll(re, str) { function findAll(re, str) {
const out = []; const out = [];
re.lastIndex = 0; re.lastIndex = 0;
let m; for (;;) {
while ((m = re.exec(str)) !== null) { const m = re.exec(str);
if (m === null) break;
out.push({ value: m[0], byteOffset: Buffer.byteLength(str.slice(0, m.index), 'utf8') }); out.push({ value: m[0], byteOffset: Buffer.byteLength(str.slice(0, m.index), 'utf8') });
if (m.index === re.lastIndex) re.lastIndex++; if (m.index === re.lastIndex) re.lastIndex++;
} }

View File

@@ -2,18 +2,23 @@
// queries across them. Workers are kept alive between requests so the chunks // queries across them. Workers are kept alive between requests so the chunks
// stay loaded in memory (replaces the per-request `php searchmultithreads.php` // stay loaded in memory (replaces the per-request `php searchmultithreads.php`
// fork from the PHP version). // fork from the PHP version).
//
// A filesystem watcher detects when the cron rewrites the chunks and recycles
// the worker pool transparently — no server restart needed.
import { Worker } from 'node:worker_threads'; import { existsSync, watch } from 'node:fs';
import { join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path'; import { Worker } from 'node:worker_threads';
import { existsSync } from 'node:fs'; import { NB_WORKERS, TMDBINTEGRAL_DIR } from '../config.js';
import { TMDBINTEGRAL_DIR, NB_WORKERS } from '../config.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const WORKER_PATH = join(__dirname, 'searchWorker.js'); const WORKER_PATH = join(__dirname, 'searchWorker.js');
const pools = new Map(); const pools = new Map();
let watcher = null;
let reloadTimer = null;
const RELOAD_DEBOUNCE_MS = 5000;
class WorkerPool { class WorkerPool {
constructor(type) { constructor(type) {
@@ -55,10 +60,45 @@ class WorkerPool {
} }
}); });
} }
async terminate() {
await Promise.allSettled(this.workers.map((w) => w.terminate()));
this.workers = [];
}
}
async function reloadAllPools() {
const types = [...pools.keys()];
console.log(`Reloading search pools: ${types.join(', ')}`);
for (const type of types) {
const old = pools.get(type);
pools.set(type, new WorkerPool(type));
old.terminate().catch(() => {
/* ignore */
});
}
}
function ensureWatcher() {
if (watcher) return;
try {
watcher = watch(TMDBINTEGRAL_DIR, (_event, filename) => {
if (!filename) return;
if (!/^search(movie|tv)\d+\.json$/.test(filename)) return;
clearTimeout(reloadTimer);
reloadTimer = setTimeout(reloadAllPools, RELOAD_DEBOUNCE_MS);
});
watcher.unref();
} catch (err) {
console.warn(`Cannot watch ${TMDBINTEGRAL_DIR} for chunk reload:`, err.message);
}
} }
export function getPool(type) { export function getPool(type) {
if (!pools.has(type)) pools.set(type, new WorkerPool(type)); if (!pools.has(type)) {
pools.set(type, new WorkerPool(type));
ensureWatcher();
}
return pools.get(type); return pools.get(type);
} }

View File

@@ -3,10 +3,8 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { parentPort, workerData } from 'node:worker_threads'; import { parentPort, workerData } from 'node:worker_threads';
import { LEV_DEL, LEV_INS, LEV_REP, LEV_SCALE, TITLE_TOLERANCE, YEAR_TOLERANCE } from '../config.js';
import { mbLevenshtein, mbStrlen } from './mbLevenshtein.js'; 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 TMDB = 0;
const TITLE = 1; const TITLE = 1;
@@ -34,8 +32,11 @@ function loadChunk() {
function score(filteredIn, target, ftiLen) { function score(filteredIn, target, ftiLen) {
if (!target) return 0; if (!target) return 0;
const tlen = mbStrlen(target); const tlen = mbStrlen(target);
return 100 - (mbLevenshtein(filteredIn, target, LEV_INS, LEV_REP, LEV_DEL) / return (
(Math.max(ftiLen, tlen) * LEV_SCALE)) * 100; 100 -
(mbLevenshtein(filteredIn, target, LEV_INS, LEV_REP, LEV_DEL) / (Math.max(ftiLen, tlen) * LEV_SCALE)) *
100
);
} }
function search({ filteredTitleIn, yearIn }) { function search({ filteredTitleIn, yearIn }) {
@@ -49,7 +50,11 @@ function search({ filteredTitleIn, yearIn }) {
let ok = false; let ok = false;
for (const y of row[YEAR]) { for (const y of row[YEAR]) {
const dy = Math.abs(yearIn - y); const dy = Math.abs(yearIn - y);
if (dy <= YEAR_TOLERANCE) { ok = true; deltaYear = dy; break; } if (dy <= YEAR_TOLERANCE) {
ok = true;
deltaYear = dy;
break;
}
} }
if (!ok) continue; if (!ok) continue;
} }
@@ -62,7 +67,7 @@ function search({ filteredTitleIn, yearIn }) {
let pT; let pT;
if (fT) { if (fT) {
pT = (fT === fO) ? pO : score(filteredTitleIn, fT, ftiLen); pT = fT === fO ? pO : score(filteredTitleIn, fT, ftiLen);
} else pT = 0; } else pT = 0;
let pE; let pE;

539
package-lock.json generated
View File

@@ -9,14 +9,215 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@fastify/formbody": "^8.0.1", "@fastify/formbody": "^8.0.1",
"@fastify/rate-limit": "^10.3.0",
"@fastify/secure-session": "^8.1.0", "@fastify/secure-session": "^8.1.0",
"@fastify/static": "^8.0.4", "@fastify/static": "^8.0.4",
"fastify": "^5.2.0" "@node-rs/argon2": "^2.0.2",
"fastify": "^5.2.0",
"lru-cache": "^11.3.5",
"prom-client": "^15.1.3"
},
"devDependencies": {
"@biomejs/biome": "^2.4.13"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/@biomejs/biome": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.13.tgz",
"integrity": "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
"biome": "bin/biome"
},
"engines": {
"node": ">=14.21.3"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.4.13",
"@biomejs/cli-darwin-x64": "2.4.13",
"@biomejs/cli-linux-arm64": "2.4.13",
"@biomejs/cli-linux-arm64-musl": "2.4.13",
"@biomejs/cli-linux-x64": "2.4.13",
"@biomejs/cli-linux-x64-musl": "2.4.13",
"@biomejs/cli-win32-arm64": "2.4.13",
"@biomejs/cli-win32-x64": "2.4.13"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.13.tgz",
"integrity": "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.13.tgz",
"integrity": "sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.13.tgz",
"integrity": "sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.13.tgz",
"integrity": "sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.13.tgz",
"integrity": "sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.13.tgz",
"integrity": "sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.13.tgz",
"integrity": "sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.4.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.13.tgz",
"integrity": "sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@fastify/accept-negotiator": { "node_modules/@fastify/accept-negotiator": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
@@ -184,6 +385,27 @@
"ipaddr.js": "^2.1.0" "ipaddr.js": "^2.1.0"
} }
}, },
"node_modules/@fastify/rate-limit": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz",
"integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"fastify-plugin": "^5.0.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/@fastify/secure-session": { "node_modules/@fastify/secure-session": {
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/@fastify/secure-session/-/secure-session-8.3.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/secure-session/-/secure-session-8.3.0.tgz",
@@ -273,12 +495,292 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@node-rs/argon2": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.0.2.tgz",
"integrity": "sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@node-rs/argon2-android-arm-eabi": "2.0.2",
"@node-rs/argon2-android-arm64": "2.0.2",
"@node-rs/argon2-darwin-arm64": "2.0.2",
"@node-rs/argon2-darwin-x64": "2.0.2",
"@node-rs/argon2-freebsd-x64": "2.0.2",
"@node-rs/argon2-linux-arm-gnueabihf": "2.0.2",
"@node-rs/argon2-linux-arm64-gnu": "2.0.2",
"@node-rs/argon2-linux-arm64-musl": "2.0.2",
"@node-rs/argon2-linux-x64-gnu": "2.0.2",
"@node-rs/argon2-linux-x64-musl": "2.0.2",
"@node-rs/argon2-wasm32-wasi": "2.0.2",
"@node-rs/argon2-win32-arm64-msvc": "2.0.2",
"@node-rs/argon2-win32-ia32-msvc": "2.0.2",
"@node-rs/argon2-win32-x64-msvc": "2.0.2"
}
},
"node_modules/@node-rs/argon2-android-arm-eabi": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.0.2.tgz",
"integrity": "sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-android-arm64": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.0.2.tgz",
"integrity": "sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-darwin-arm64": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.0.2.tgz",
"integrity": "sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-darwin-x64": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.0.2.tgz",
"integrity": "sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-freebsd-x64": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.0.2.tgz",
"integrity": "sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-arm-gnueabihf": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.0.2.tgz",
"integrity": "sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-arm64-gnu": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.0.2.tgz",
"integrity": "sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-arm64-musl": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.0.2.tgz",
"integrity": "sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-x64-gnu": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.0.2.tgz",
"integrity": "sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-linux-x64-musl": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.0.2.tgz",
"integrity": "sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-wasm32-wasi": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-2.0.2.tgz",
"integrity": "sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^0.2.5"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@node-rs/argon2-win32-arm64-msvc": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.0.2.tgz",
"integrity": "sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-win32-ia32-msvc": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.0.2.tgz",
"integrity": "sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@node-rs/argon2-win32-x64-msvc": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.0.2.tgz",
"integrity": "sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@pinojs/redact": { "node_modules/@pinojs/redact": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/abstract-logging": { "node_modules/abstract-logging": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@@ -499,6 +1001,12 @@
"bare": ">=1.2.0" "bare": ">=1.2.0"
} }
}, },
"node_modules/bintrees": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz",
"integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==",
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.5", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
@@ -1018,6 +1526,19 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/prom-client": {
"version": "15.1.3",
"resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz",
"integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.4.0",
"tdigest": "^0.1.1"
},
"engines": {
"node": "^16 || ^18 || >=20"
}
},
"node_modules/quick-format-unescaped": { "node_modules/quick-format-unescaped": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
@@ -1255,6 +1776,15 @@
"text-decoder": "^1.1.0" "text-decoder": "^1.1.0"
} }
}, },
"node_modules/tdigest": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz",
"integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==",
"license": "MIT",
"dependencies": {
"bintrees": "1.0.2"
}
},
"node_modules/teex": { "node_modules/teex": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
@@ -1303,6 +1833,13 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -16,12 +16,22 @@
"cron:tmdb2imdb": "node --env-file-if-exists=.env cron/tmdb2imdb.js", "cron:tmdb2imdb": "node --env-file-if-exists=.env cron/tmdb2imdb.js",
"cron:search": "node --env-file-if-exists=.env cron/buildSearch.js", "cron:search": "node --env-file-if-exists=.env cron/buildSearch.js",
"cron:ambiguity": "node --env-file-if-exists=.env cron/ambiguity.js", "cron:ambiguity": "node --env-file-if-exists=.env cron/ambiguity.js",
"test": "node --env-file-if-exists=.env --test test/*.test.js" "test": "node --env-file-if-exists=.env --test test/*.test.js",
"lint": "biome check",
"format": "biome format --write",
"fix": "biome check --write"
}, },
"dependencies": { "dependencies": {
"@fastify/formbody": "^8.0.1", "@fastify/formbody": "^8.0.1",
"@fastify/rate-limit": "^10.3.0",
"@fastify/secure-session": "^8.1.0", "@fastify/secure-session": "^8.1.0",
"@fastify/static": "^8.0.4", "@fastify/static": "^8.0.4",
"fastify": "^5.2.0" "@node-rs/argon2": "^2.0.2",
"fastify": "^5.2.0",
"lru-cache": "^11.3.5",
"prom-client": "^15.1.3"
},
"devDependencies": {
"@biomejs/biome": "^2.4.13"
} }
} }

307
public/app.js Normal file
View File

@@ -0,0 +1,307 @@
// proxytmdb — UI vanilla JS
// Single-page app: search bar -> grid of results -> click for detail dialog.
const $ = (sel) => document.querySelector(sel);
const form = $('#search-form');
const input = $('#q');
const status = $('#status');
const results = $('#results');
const dialog = $('#detail');
const dialogBody = $('#detail-body');
const NO_POSTER =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300"><rect width="200" height="300" fill="#0f172a"/><text x="100" y="150" text-anchor="middle" fill="#475569" font-family="sans-serif" font-size="14">no poster</text></svg>',
);
const escapeHtml = (s) =>
String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
function showStatus(text, kind = '') {
status.className = `status ${kind}`;
status.textContent = text;
status.classList.remove('hidden');
}
function hideStatus() {
status.classList.add('hidden');
}
function isImdbId(s) {
return /^tt\d{6,}$/.test(s.trim());
}
function _isTmdbUrl(s) {
return /themoviedb\.org\/(movie|tv)\/(\d+)/.test(s);
}
async function searchAndRender(query) {
query = query.trim();
if (!query) return;
results.innerHTML = '';
showStatus('Recherche en cours', 'loading');
let endpoint;
if (isImdbId(query)) {
endpoint = `/api?t=imdb&q=${encodeURIComponent(query)}`;
} else {
const m = query.match(/themoviedb\.org\/(movie|tv)\/(\d+)/);
if (m) endpoint = `/api?t=${m[1]}&q=${m[2]}`;
else endpoint = `/api?t=search&q=${encodeURIComponent(query)}`;
}
let data;
try {
const res = await fetch(endpoint);
data = await res.json();
} catch (err) {
showStatus(`Erreur réseau : ${err.message}`, 'error');
return;
}
if (data.error) {
showStatus(data.error, 'error');
return;
}
// Single-entry response (movie/tv/imdb endpoint) -> open detail directly.
if (!data.results) {
hideStatus();
openDetailFromEntry(data);
return;
}
if (!data.results.length) {
showStatus('Aucun résultat', '');
return;
}
hideStatus();
renderResults(data.results);
}
function renderResults(items) {
const frag = document.createDocumentFragment();
for (const item of items) {
const card = document.createElement('article');
card.className = 'card';
card.tabIndex = 0;
card.dataset.tmdbId = item.tmdb_id;
card.dataset.type = item.api_url?.includes('t=tv') ? 'tv' : 'movie';
const poster = item.poster_path ? `https://image.tmdb.org/t/p/w300${item.poster_path}` : NO_POSTER;
const title = item.title || item.english_title || item.original_title || '';
const year = item.years || '';
const note_imdb = item.note_imdb;
const note_tmdb = item.note_tmdb;
card.innerHTML = `
<img class="card-poster" src="${escapeHtml(poster)}" alt="${escapeHtml(title)}" loading="lazy">
<div class="card-body">
<div class="card-title">${escapeHtml(title)}</div>
<div class="card-meta">
${year ? `<span>${escapeHtml(year)}</span>` : ''}
${item.runtime ? `<span>${escapeHtml(item.runtime)}</span>` : ''}
${item.season ? `<span>${escapeHtml(item.season)}</span>` : ''}
</div>
<div class="card-ratings">
${note_tmdb ? `<span class="rating tmdb">TMDB ${note_tmdb}</span>` : ''}
${note_imdb ? `<span class="rating imdb">IMDb ${note_imdb}</span>` : ''}
</div>
</div>
`;
card.addEventListener('click', () => openDetailFromCard(item));
card.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openDetailFromCard(item);
}
});
frag.appendChild(card);
}
results.appendChild(frag);
}
// Open detail from a card item (already has summary; we still fetch full detail
// for tagline/overview which may be missing depending on result shape).
async function openDetailFromCard(item) {
const type = item.api_url?.includes('t=tv') ? 'tv' : 'movie';
showDialog(`<div class="status loading">Chargement…</div>`);
let full;
try {
const res = await fetch(`/api?t=${type}&q=${item.tmdb_id}`);
full = await res.json();
} catch {
full = {};
}
renderDetail({ summary: item, full, type });
loadProviders(type, item.tmdb_id);
}
async function openDetailFromEntry(detail) {
// Detail came directly from /api?t=movie|tv|imdb : we don't have a "summary"
// shape, so synthesize minimal fields.
const type = detail?.first_air_date ? 'tv' : 'movie';
const summary = {
tmdb_id: detail.id,
title: detail.title || detail.name,
original_title: detail.original_title || detail.original_name,
years: (detail.release_date || detail.first_air_date || '').slice(0, 4),
poster_path: detail.poster_path,
note_tmdb: detail.vote_average ? Math.round(detail.vote_average * 10) / 10 : null,
note_imdb: detail.note_imdb,
runtime: detail.runtime
? `${Math.floor(detail.runtime / 60)} h ${String(detail.runtime % 60).padStart(2, '0')} min`
: '',
};
renderDetail({ summary, full: detail, type });
loadProviders(type, detail.id);
showDialog(dialogBody.innerHTML);
}
function showDialog(html) {
dialogBody.innerHTML = html;
if (!dialog.open) dialog.showModal();
}
function renderDetail({ summary, full, type }) {
const poster = summary.poster_path ? `https://image.tmdb.org/t/p/w400${summary.poster_path}` : NO_POSTER;
const title = summary.title || full.title || full.name || '';
const altTitle = summary.original_title || full.original_title || full.original_name || '';
const showAlt = altTitle && altTitle !== title;
const genres = (full.genres || []).map((g) => g.name).join(' · ');
const countries = (full.production_countries || []).map((c) => c.iso_3166_1).join(' ');
const runtime = full.runtime
? `${Math.floor(full.runtime / 60)} h ${String(full.runtime % 60).padStart(2, '0')} min`
: '';
const tmdbId = summary.tmdb_id || full.id;
const imdbId = full.imdb_id || full?.external_ids?.imdb_id;
const tmdbUrl = `https://www.themoviedb.org/${type}/${tmdbId}`;
const imdbUrl = imdbId ? `https://www.imdb.com/title/${imdbId}` : null;
const noteTmdb = full.vote_average ? Math.round(full.vote_average * 10) / 10 : summary.note_tmdb;
const voteTmdb = full.vote_count;
const noteImdb = full.note_imdb || summary.note_imdb;
const voteImdb = full.vote_imdb || summary.vote_imdb;
const fmtMoney = (n) =>
n
? new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(n)
: '';
showDialog(`
<div class="detail-poster">
<img src="${escapeHtml(poster)}" alt="${escapeHtml(title)}">
</div>
<div class="detail-info">
<h2>${escapeHtml(title)} ${summary.years ? `<span style="color:var(--text-muted);font-weight:400">(${escapeHtml(summary.years)})</span>` : ''}</h2>
${showAlt ? `<div class="alt-title">${escapeHtml(altTitle)}</div>` : ''}
<div class="badges">
${genres ? `<span class="badge">${escapeHtml(genres)}</span>` : ''}
${countries ? `<span class="badge">${escapeHtml(countries)}</span>` : ''}
${runtime ? `<span class="badge">${escapeHtml(runtime)}</span>` : ''}
${summary.season ? `<span class="badge">${escapeHtml(summary.season)}</span>` : ''}
</div>
<div class="links">
<a class="link-pill tmdb" href="${escapeHtml(tmdbUrl)}" target="_blank" rel="noopener">
TMDB ${noteTmdb ? `· ${noteTmdb}` : ''} ${voteTmdb ? `<span style="opacity:.6">(${voteTmdb})</span>` : ''}
</a>
${
imdbUrl
? `<a class="link-pill imdb" href="${escapeHtml(imdbUrl)}" target="_blank" rel="noopener">
IMDb ${noteImdb ? `· ${noteImdb}` : ''} ${voteImdb ? `<span style="opacity:.6">(${voteImdb})</span>` : ''}
</a>`
: ''
}
</div>
${full.tagline ? `<div class="tagline">« ${escapeHtml(full.tagline)} »</div>` : ''}
${full.overview ? `<div class="overview">${escapeHtml(full.overview)}</div>` : ''}
${
full.budget || full.revenue
? `<div class="money">
${full.budget ? `<span>Budget : <b>${fmtMoney(full.budget)}</b></span>` : ''}
${full.revenue ? `<span>Revenue : <b>${fmtMoney(full.revenue)}</b></span>` : ''}
</div>`
: ''
}
<div id="providers-section" class="hidden">
<h3>Disponible sur (FR)</h3>
<div id="providers-list" class="providers-list"></div>
</div>
</div>
`);
}
async function loadProviders(type, tmdbId) {
let data;
try {
const res = await fetch(`/api?t=providers&type=${type}&q=${tmdbId}`);
data = await res.json();
} catch {
return;
}
if (!data || data.error) return;
const fr = data.results?.FR;
if (!fr) return;
const set = new Map();
for (const k of ['flatrate', 'rent', 'buy', 'free', 'ads']) {
for (const p of fr[k] || []) set.set(p.provider_id, p);
}
if (!set.size) return;
const section = document.getElementById('providers-section');
const list = document.getElementById('providers-list');
list.innerHTML = [...set.values()]
.map(
(p) => `
<span class="provider" title="${escapeHtml(p.provider_name)}">
${p.logo_path ? `<img src="https://image.tmdb.org/t/p/w45${p.logo_path}" alt="">` : ''}
${escapeHtml(p.provider_name)}
</span>
`,
)
.join('');
section.classList.remove('hidden');
}
// Close dialog on backdrop click or close button
dialog.addEventListener('click', (e) => {
if (e.target === dialog || e.target.dataset.action === 'close') dialog.close();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && dialog.open) dialog.close();
});
form.addEventListener('submit', (e) => {
e.preventDefault();
const q = input.value;
if (!q) return;
history.pushState({ q }, '', `?q=${encodeURIComponent(q)}`);
searchAndRender(q);
});
window.addEventListener('popstate', () => {
const q = new URLSearchParams(location.search).get('q') || '';
input.value = q;
if (q) searchAndRender(q);
});
// Initial load: ?q=... in URL
const initial = new URLSearchParams(location.search).get('q');
if (initial) {
input.value = initial;
searchAndRender(initial);
}

40
public/index.html Normal file
View File

@@ -0,0 +1,40 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="theme-color" content="#0b1220">
<title>proxytmdb · Recherche</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%2301b4e4'/%3E%3Ctext x='16' y='22' font-family='sans-serif' font-size='18' font-weight='bold' text-anchor='middle' fill='%23000'%3Et%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header class="topbar">
<a class="brand" href="/">
<span class="brand-mark">t</span>
<span class="brand-text">proxytmdb</span>
</a>
<form id="search-form" class="search-form" autocomplete="off">
<input id="q" type="search" name="q" placeholder="ex: Inception 2010, Mr.Robot S01E02, tt0133093…" >
<button type="submit" class="btn">Chercher</button>
</form>
<a class="nav-link" href="https://www.themoviedb.org/" target="_blank" rel="noopener">TMDB</a>
</header>
<main id="main">
<section id="status" class="status hidden"></section>
<section id="results" class="results"></section>
</main>
<dialog id="detail" class="detail">
<button type="button" class="close" aria-label="Fermer" data-action="close">×</button>
<div id="detail-body" class="detail-body"></div>
</dialog>
<footer class="footer">
<span id="footer-text">proxytmdb · Cache local TMDB + notes IMDb</span>
</footer>
<script type="module" src="/app.js"></script>
</body>
</html>

445
public/style.css Normal file
View File

@@ -0,0 +1,445 @@
:root {
--bg: #0b1220;
--bg-2: #0f172a;
--bg-3: #111827;
--bg-hover: #1f2937;
--border: #1f2937;
--text: #e5e7eb;
--text-muted: #94a3b8;
--text-dim: #64748b;
--accent: #01b4e4; /* TMDB blue */
--accent-2: #60a5fa;
--imdb: #f3ce13;
--danger: #fca5a5;
--radius: 12px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
line-height: 1.5;
min-height: 100vh;
}
a {
color: var(--accent-2);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Topbar -------------------------------------------------------------- */
.topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 20px;
background: var(--bg-2);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
backdrop-filter: blur(10px);
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--text);
font-weight: 700;
font-size: 16px;
text-decoration: none;
flex-shrink: 0;
}
.brand:hover {
text-decoration: none;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: var(--accent);
color: #000;
border-radius: 6px;
font-weight: 800;
}
.search-form {
flex: 1;
display: flex;
gap: 8px;
max-width: 720px;
}
.search-form input {
flex: 1;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-3);
color: var(--text);
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
.search-form input:focus {
border-color: var(--accent-2);
}
.search-form input::placeholder {
color: var(--text-dim);
}
.btn {
padding: 10px 18px;
border: 0;
border-radius: 8px;
background: var(--accent);
color: #000;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s;
}
.btn:hover {
filter: brightness(1.1);
}
.btn:active {
filter: brightness(0.95);
}
.nav-link {
color: var(--text-muted);
font-size: 13px;
}
/* Main ---------------------------------------------------------------- */
#main {
max-width: 1400px;
margin: 0 auto;
padding: 24px 20px 80px;
}
.status {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-size: 14px;
}
.status.error {
color: var(--danger);
}
.status.loading::after {
content: "";
display: inline-block;
width: 16px;
height: 16px;
margin-left: 10px;
border: 2px solid var(--text-muted);
border-top-color: transparent;
border-radius: 50%;
vertical-align: middle;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.hidden {
display: none;
}
/* Results grid -------------------------------------------------------- */
.results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px;
}
.card {
background: var(--bg-3);
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
transition:
transform 0.15s,
box-shadow 0.15s;
display: flex;
flex-direction: column;
border: 1px solid transparent;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
border-color: var(--bg-hover);
}
.card-poster {
width: 100%;
aspect-ratio: 2 / 3;
background: var(--bg-2);
background-image: linear-gradient(135deg, var(--bg-2), var(--bg-3));
object-fit: cover;
display: block;
}
.card-poster.no-poster {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
font-size: 12px;
}
.card-body {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.card-title {
font-weight: 600;
font-size: 14px;
line-height: 1.3;
color: var(--text);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-meta {
font-size: 12px;
color: var(--text-muted);
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
margin-top: auto;
}
.card-ratings {
display: flex;
gap: 8px;
margin-top: 4px;
flex-wrap: wrap;
}
.rating {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
}
.rating.tmdb {
background: var(--accent);
color: #000;
}
.rating.imdb {
background: var(--imdb);
color: #000;
}
/* Detail dialog ------------------------------------------------------- */
.detail {
border: 0;
padding: 0;
background: var(--bg-3);
color: var(--text);
border-radius: var(--radius);
max-width: 900px;
width: 92%;
max-height: 90vh;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
}
.detail::backdrop {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
.detail .close {
position: absolute;
top: 8px;
right: 12px;
background: transparent;
border: 0;
color: var(--text);
font-size: 28px;
cursor: pointer;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
z-index: 2;
}
.detail .close:hover {
background: var(--bg-hover);
}
.detail-body {
display: flex;
gap: 24px;
padding: 24px;
}
.detail-poster {
width: 220px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
}
.detail-poster img {
width: 100%;
display: block;
}
.detail-info {
flex: 1;
min-width: 0;
}
.detail-info h2 {
margin: 0 0 8px;
font-size: 22px;
color: #f9fafb;
}
.detail-info .alt-title {
color: var(--text-muted);
font-size: 14px;
font-style: italic;
margin-bottom: 12px;
}
.detail-info .badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 14px;
}
.badge {
font-size: 11px;
padding: 3px 9px;
border-radius: 999px;
background: var(--bg-hover);
color: var(--text-muted);
}
.detail-info .links {
display: flex;
gap: 8px;
margin: 14px 0;
}
.link-pill {
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
text-decoration: none;
}
.link-pill.tmdb {
background: var(--accent);
color: #000;
}
.link-pill.imdb {
background: var(--imdb);
color: #000;
}
.link-pill:hover {
text-decoration: none;
filter: brightness(1.1);
}
.detail-info .tagline {
font-style: italic;
color: var(--text-muted);
margin: 12px 0;
}
.detail-info .overview {
color: var(--text);
line-height: 1.6;
margin: 12px 0;
}
.detail-info .money {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--text-muted);
margin-top: 12px;
}
#providers-section {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
#providers-section h3 {
font-size: 14px;
margin: 0 0 10px;
color: var(--text-muted);
font-weight: 600;
}
.providers-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.provider {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--bg-hover);
border-radius: 6px;
font-size: 12px;
}
.provider img {
width: 20px;
height: 20px;
border-radius: 4px;
}
@media (max-width: 600px) {
.topbar {
flex-wrap: wrap;
}
.nav-link {
display: none;
}
.detail-body {
flex-direction: column;
padding: 16px;
gap: 16px;
}
.detail-poster {
width: 140px;
margin: 0 auto;
}
}
/* Footer -------------------------------------------------------------- */
.footer {
text-align: center;
padding: 16px;
color: var(--text-dim);
font-size: 12px;
}

View File

@@ -1,10 +1,33 @@
// Login + protected directory listing — replaces index.php. // Protected file listing (was the public root in the PHP version).
// Now mounted at /admin so the new public UI can live at /.
import { readdir, stat } from 'node:fs/promises'; import { readdir, stat } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { ROOT, TITLE, PASSWORD } from '../config.js'; import { ADMIN_PASSWORD_HASH, ROOT, TITLE } from '../config.js';
import { verifyPassword } from '../lib/password.js';
const HIDDEN = new Set(['index.php', '.htaccess', 'node_modules', 'package.json', 'package-lock.json', 'config.js', 'server.js', 'lib', 'routes', 'cron', 'test', '.git']); const HIDDEN = new Set([
'node_modules',
'package.json',
'package-lock.json',
'config.js',
'server.js',
'lib',
'routes',
'cron',
'tools',
'test',
'.git',
'.claude',
'.specstory',
'biome.json',
'public',
'.env',
'.env.example',
'.gitignore',
'README.md',
'.cron.lock',
]);
function esc(s) { function esc(s) {
if (s == null) return ''; if (s == null) return '';
@@ -20,10 +43,12 @@ function formatBytes(bytes) {
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let i = bytes > 0 ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0; let i = bytes > 0 ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0;
if (i >= units.length) i = units.length - 1; if (i >= units.length) i = units.length - 1;
return `${(bytes / Math.pow(1024, i)) || 0} ${units[i]}`; return `${bytes / 1024 ** i || 0} ${units[i]}`;
} }
function pad(n) { return n < 10 ? `0${n}` : String(n); } function pad(n) {
return n < 10 ? `0${n}` : String(n);
}
function fmtDate(ms) { function fmtDate(ms) {
const d = new Date(ms); const d = new Date(ms);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
@@ -35,7 +60,7 @@ function loginPage(error = '') {
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(TITLE)}</title> <title>Admin · ${esc(TITLE)}</title>
<style> <style>
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background:#0b1220; color:#e5e7eb; display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; } body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background:#0b1220; color:#e5e7eb; display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; }
@@ -51,7 +76,7 @@ input[type=password]:focus{ border-color:#60a5fa; }
</head> </head>
<body> <body>
<form class="card" method="post" autocomplete="off"> <form class="card" method="post" autocomplete="off">
<h1>${esc(TITLE)}</h1> <h1>Admin · ${esc(TITLE)}</h1>
<label for="pw">Mot de passe</label> <label for="pw">Mot de passe</label>
<input id="pw" name="password" type="password" required autofocus> <input id="pw" name="password" type="password" required autofocus>
<button class="btn" type="submit">Entrer</button> <button class="btn" type="submit">Entrer</button>
@@ -68,7 +93,11 @@ async function listingPage() {
if (HIDDEN.has(name)) continue; if (HIDDEN.has(name)) continue;
if (name.startsWith('.')) continue; if (name.startsWith('.')) continue;
let st; let st;
try { st = await stat(join(ROOT, name)); } catch { continue; } try {
st = await stat(join(ROOT, name));
} catch {
continue;
}
entries.push({ entries.push({
name, name,
isDir: st.isDirectory(), isDir: st.isDirectory(),
@@ -80,28 +109,24 @@ async function listingPage() {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
}); });
const rows = entries
const rows = entries.map((e) => { .map((e) => {
const href = encodeURIComponent(e.name); const href = encodeURIComponent(e.name);
return `<tr> return `<tr>
<td class="name"><a href="${href}${e.isDir ? '/' : ''}">${esc(e.name)}</a>${e.isDir ? '<span class="badge">dossier</span>' : ''}</td> <td class="name"><a href="/admin/files/${href}${e.isDir ? '/' : ''}">${esc(e.name)}</a>${e.isDir ? '<span class="badge">dossier</span>' : ''}</td>
<td>${e.isDir ? '—' : esc(formatBytes(e.size))}</td> <td>${e.isDir ? '—' : esc(formatBytes(e.size))}</td>
<td>${esc(fmtDate(e.mtime))}</td> <td>${esc(fmtDate(e.mtime))}</td>
</tr>`; </tr>`;
}).join(''); })
.join('');
return `<!doctype html> return `<!doctype html>
<html lang="fr"> <html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<head> <title>Admin · ${esc(TITLE)}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(TITLE)}</title>
<style> <style>
body{ font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:#0b1220; color:#e5e7eb; margin:0; } body{ font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:#0b1220; color:#e5e7eb; margin:0; }
header{ display:flex; gap:12px; align-items:center; justify-content:space-between; padding:16px 20px; background:#0f172a; position:sticky; top:0; } header{ display:flex; gap:12px; align-items:center; justify-content:space-between; padding:16px 20px; background:#0f172a; position:sticky; top:0; }
h1{ font-size:18px; margin:0; color:#f9fafb; } h1{ font-size:18px; margin:0; color:#f9fafb; }
a.logout{ color:#93c5fd; text-decoration:none; font-size:14px; } a.logout{ color:#93c5fd; text-decoration:none; font-size:14px; }
a.logout:hover{ text-decoration:underline; }
.wrap{ max-width:1100px; margin:20px auto; padding:0 16px; } .wrap{ max-width:1100px; margin:20px auto; padding:0 16px; }
table{ width:100%; border-collapse:collapse; background:#111827; border-radius:12px; overflow:hidden; } table{ width:100%; border-collapse:collapse; background:#111827; border-radius:12px; overflow:hidden; }
th,td{ padding:12px 14px; border-bottom:1px solid #1f2937; text-align:left; font-size:14px; } th,td{ padding:12px 14px; border-bottom:1px solid #1f2937; text-align:left; font-size:14px; }
@@ -110,53 +135,31 @@ tr:hover td{ background:#0b1324; }
.name a{ color:#93c5fd; text-decoration:none; } .name a{ color:#93c5fd; text-decoration:none; }
.name a:hover{ text-decoration:underline; } .name a:hover{ text-decoration:underline; }
.badge{ font-size:11px; padding:2px 8px; border-radius:999px; background:#1f2937; color:#cbd5e1; margin-left:8px; } .badge{ font-size:11px; padding:2px 8px; border-radius:999px; background:#1f2937; color:#cbd5e1; margin-left:8px; }
footer{ color:#94a3b8; font-size:12px; text-align:center; padding:16px; } </style></head>
</style> <body><header><h1>Admin · ${esc(TITLE)}</h1><a class="logout" href="/admin?logout=1">Se déconnecter</a></header>
</head> <div class="wrap"><table><thead><tr><th>Nom</th><th>Taille</th><th>Modifié</th></tr></thead><tbody>${rows}</tbody></table></div>
<body> </body></html>`;
<header>
<h1>${esc(TITLE)}</h1>
<a class="logout" href="?logout=1">Se déconnecter</a>
</header>
<div class="wrap">
<table>
<thead><tr><th>Nom</th><th>Taille</th><th>Modifié</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<footer>Les liens ci-dessus pointent directement vers les fichiers/dossiers non protégés.</footer>
</div>
</body>
</html>`;
} }
export default async function indexRoutes(fastify) { export default async function adminRoutes(fastify) {
fastify.get('/', async (req, reply) => { fastify.get('/admin', async (req, reply) => {
reply.header('Content-Type', 'text/html; charset=utf-8'); reply.header('Content-Type', 'text/html; charset=utf-8');
if (req.query?.logout != null) { if (req.query?.logout != null) {
req.session.delete(); req.session.delete();
return reply.redirect('/'); return reply.redirect('/admin');
}
if (!req.session.get('auth_ok')) {
return loginPage();
} }
if (!req.session.get('auth_ok')) return loginPage();
return listingPage(); return listingPage();
}); });
fastify.post('/', async (req, reply) => { fastify.post('/admin', async (req, reply) => {
reply.header('Content-Type', 'text/html; charset=utf-8'); reply.header('Content-Type', 'text/html; charset=utf-8');
const submitted = req.body?.password ?? ''; const submitted = req.body?.password ?? '';
if (timingSafeEqual(submitted, PASSWORD)) { const ok = await verifyPassword(ADMIN_PASSWORD_HASH, submitted);
if (ok) {
req.session.set('auth_ok', true); req.session.set('auth_ok', true);
return reply.redirect('/'); return reply.redirect('/admin');
} }
return loginPage('Mot de passe incorrect.'); 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;
}

View File

@@ -1,18 +1,24 @@
// JSON API — replaces api.php. // JSON API.
// GET /api?t=movie&q=<id> //
// GET /api?t=tv&q=<id> // GET /api?t=movie&q=<id> -> TMDB detail (movie) + IMDb note
// GET /api?t=search&q=<query> // GET /api?t=tv&q=<id> -> TMDB detail (tv) + IMDb note
// GET /api?t=imdb&q=<imdb_id> -> redirect-style: returns movie or tv detail
// GET /api?t=providers&type=movie&q=<id> -> watch providers JSON
// GET /api?t=search&q=<query> -> ranked search results
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { entryPath } from '../lib/paths.js'; import { LRUCache } from 'lru-cache';
import { getRatings, lookupRating } from '../lib/imdbRatings.js'; import { IMDB_URL, MOVIE_API_URL, MOVIE_URL, POSTER_URL, TV_API_URL, TV_URL } from '../config.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 { formatCurrency, formatRuntime, pad2 } from '../lib/format.js';
import { import { lookupImdb } from '../lib/imdbMapping.js';
POSTER_URL, MOVIE_URL, TV_URL, MOVIE_API_URL, TV_API_URL, IMDB_URL, import { getRatings, lookupRating } from '../lib/imdbRatings.js';
} from '../config.js'; import { searchCacheHits, searchCacheMisses } from '../lib/metrics.js';
import { entryPath, justwatchPath } from '../lib/paths.js';
import { parseQuery } from '../lib/queryParser.js';
import { search as runSearch } from '../lib/searchEngine.js';
import { filterAndLower } from '../lib/titleFilter.js';
const searchCache = new LRUCache({ max: 1000, ttl: 1000 * 60 * 60 });
async function getDetail(type, id) { async function getDetail(type, id) {
try { try {
@@ -23,10 +29,17 @@ async function getDetail(type, id) {
} }
} }
async function getProviders(type, id) {
try {
return JSON.parse(await readFile(justwatchPath(type, id), 'utf8'));
} catch {
return null;
}
}
async function handleEntry(type, id) { async function handleEntry(type, id) {
const obj = await getDetail(type, id); const obj = await getDetail(type, id);
if (!obj) return { error: 'Not found' }; if (!obj) return { error: 'Not found' };
const imdb = type === 'movie' ? obj.imdb_id : obj?.external_ids?.imdb_id; const imdb = type === 'movie' ? obj.imdb_id : obj?.external_ids?.imdb_id;
if (imdb) { if (imdb) {
const ratings = await getRatings(); const ratings = await getRatings();
@@ -39,7 +52,20 @@ async function handleEntry(type, id) {
return obj; return obj;
} }
async function handleImdbLookup(imdbId) {
const found = await lookupImdb(imdbId);
if (!found) return { error: 'IMDb id not found in local mappings' };
return handleEntry(found.type, found.tmdb);
}
async function handleSearch(query) { async function handleSearch(query) {
const cached = searchCache.get(query);
if (cached) {
searchCacheHits.inc();
return cached;
}
searchCacheMisses.inc();
const parsed = parseQuery(query); const parsed = parseQuery(query);
if (!parsed) return null; if (!parsed) return null;
if (parsed.error) return { error: parsed.error }; if (parsed.error) return { error: parsed.error };
@@ -49,7 +75,9 @@ async function handleSearch(query) {
const matches = await runSearch(type, filteredTitleIn, yearin); const matches = await runSearch(type, filteredTitleIn, yearin);
if (!matches.length) { if (!matches.length) {
return { error: 'Not found in localized and original titles database' }; const out = { error: 'Not found in localized and original titles database' };
searchCache.set(query, out);
return out;
} }
const ratings = await getRatings(); const ratings = await getRatings();
@@ -108,8 +136,6 @@ async function handleSearch(query) {
} }
if (episodein && Array.isArray(detail.seasons)) { 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; let lastSeason;
for (const s of detail.seasons) { for (const s of detail.seasons) {
const sn = pad2(s.season_number || 0); const sn = pad2(s.season_number || 0);
@@ -125,7 +151,9 @@ async function handleSearch(query) {
results.push(item); results.push(item);
} }
return { results }; const out = { results };
searchCache.set(query, out);
return out;
} }
async function handle(req, reply) { async function handle(req, reply) {
@@ -142,6 +170,22 @@ async function handle(req, reply) {
return handleEntry(t, id); return handleEntry(t, id);
} }
if (t === 'imdb') {
if (!q) return {};
return handleImdbLookup(String(q));
}
if (t === 'providers') {
const type = req.query?.type;
if (!q || (type !== 'movie' && type !== 'tv')) {
return { error: 'providers requires type=movie|tv and q=<id>' };
}
const id = parseInt(q, 10);
if (!Number.isInteger(id)) return {};
const data = await getProviders(type, id);
return data ?? { error: 'Providers not found' };
}
if (t === 'search') { if (t === 'search') {
if (!q) return reply.send(''); if (!q) return reply.send('');
return (await handleSearch(q)) ?? {}; return (await handleSearch(q)) ?? {};

35
routes/health.js Normal file
View File

@@ -0,0 +1,35 @@
// /health (JSON liveness/readiness) and /metrics (Prometheus exposition).
import { getRatings } from '../lib/imdbRatings.js';
import { imdbRatingsCount, registry } from '../lib/metrics.js';
export default async function healthRoutes(fastify) {
fastify.get('/health', async (_req, reply) => {
reply.header('Cache-Control', 'no-store');
let ratings = 0;
let ratingsOk = false;
try {
const map = await getRatings();
ratings = map.size;
imdbRatingsCount.set(ratings);
ratingsOk = true;
} catch {
/* ignore — reported via ratingsOk */
}
const status = ratingsOk ? 'ok' : 'degraded';
reply.code(ratingsOk ? 200 : 503);
return {
status,
uptime: Math.round(process.uptime()),
pid: process.pid,
node: process.version,
memory_mb: Math.round(process.memoryUsage().rss / 1024 / 1024),
imdb_ratings: ratings,
};
});
fastify.get('/metrics', async (_req, reply) => {
reply.header('Content-Type', registry.contentType);
return registry.metrics();
});
}

View File

@@ -1,144 +0,0 @@
// 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function center(msg) {
return `<div style="text-align: center;">${esc(msg)}</div>`;
}
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 = '<div style="text-align: center; font-size: 14px; font-family: sans-serif;">';
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 += '<span style="display: inline-block; margin: 10px; vertical-align: top;">';
html += `<img src="${esc(src)}" width="200" height="300"/>`;
html += '<div style="display: inline-block; white-space: normal; overflow: auto; vertical-align: top; width: 400px; height: 300px; background-color: #484848; color: #ffffff;">';
html += '<p style="margin: 10px;">';
if (m.filteredTitle) html += `FR <b>${esc(m.title)}</b> ${esc(m.year)}<br />`;
if (m.filteredEnglishTitle) html += `EN <b>${esc(m.englishTitle)}</b> ${esc(m.year)}<br />`;
if (m.filteredOriginalTitle) html += `VO <b>${esc(m.originalTitle)}</b> ${esc(m.year)}<br />`;
html += '<p style="margin: 10px;">';
if (genres) html += esc(genres);
if (countries) html += `<b>${esc(countries)}</b>`;
if (runtime) html += formatRuntime(runtime);
html += '</p>';
html += '<p style="margin: 10px;">';
if (imdb) {
html += `<a href="${esc(IMDB_URL)}/${esc(imdb)}" style="background-color: #f3ce13; color: #000000; text-decoration: none;" onclick="this.target='_blank';">`;
html += `&nbsp;IMDb&nbsp;</a> ${esc(imdb)} <b>${esc(ivote)}</b> ${esc(ivoteCount)} `;
}
html += `<a href="${esc(movietvurl)}/${esc(m.tmdb)}" style="background-color: #01b4e4; color: #000000; text-decoration: none;" onclick="this.target='_blank';">`;
html += `&nbsp;TMDb&nbsp;</a> ${esc(m.tmdb)} <b>${esc(tvote)}</b> ${esc(tvoteCount)}<br />`;
if (budget || revenue) {
html += `${esc(formatCurrency(budget))} ${esc(formatCurrency(revenue))}`;
}
html += '</p>';
html += '<p style="margin: 10px; text-align: left;">';
if (seasons) {
html += '<b>Episodes finaux</b> ';
for (const s of seasons) html += `${esc(s)} `;
}
html += '</p>';
html += '<p style="margin: 10px; text-align: left;">';
html += `<b>${esc(detail.tagline || '')}</b>`;
html += '</p>';
html += '<p style="margin: 10px; text-align: justify;">';
if (detail.overview) html += esc(detail.overview);
html += '</p>';
html += '</div></span>';
}
html += '</div>';
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);
}

View File

@@ -1,54 +1,84 @@
import Fastify from 'fastify'; import { join } from 'node:path';
import formbody from '@fastify/formbody'; import formbody from '@fastify/formbody';
import rateLimit from '@fastify/rate-limit';
import secureSession from '@fastify/secure-session'; import secureSession from '@fastify/secure-session';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import { join } from 'node:path'; import Fastify from 'fastify';
import { ROOT, PORT, HOST, SESSION_SECRET } from './config.js'; import { HOST, PORT, RATE_LIMIT_PER_SEC, ROOT, 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 { getRatings } from './lib/imdbRatings.js';
import { httpDuration, httpRequests, imdbRatingsCount, searchWorkers } from './lib/metrics.js';
import { getPool } from './lib/searchEngine.js'; import { getPool } from './lib/searchEngine.js';
import adminRoutes from './routes/admin.js';
import apiRoutes from './routes/api.js';
import healthRoutes from './routes/health.js';
const fastify = Fastify({ logger: true, trustProxy: true }); const fastify = Fastify({ logger: true, trustProxy: true });
await fastify.register(rateLimit, {
max: RATE_LIMIT_PER_SEC,
timeWindow: '1 second',
// Skip rate limiting for /health and /metrics so monitoring is never throttled
skipOnError: true,
allowList: (req) => req.url === '/health' || req.url === '/metrics',
});
await fastify.register(formbody); await fastify.register(formbody);
await fastify.register(secureSession, { await fastify.register(secureSession, {
// 32 bytes minimum. Use SESSION_SECRET env var in production.
secret: SESSION_SECRET.padEnd(32, '0').slice(0, 32), secret: SESSION_SECRET.padEnd(32, '0').slice(0, 32),
salt: 'proxytmdb-salt-1', salt: 'proxytmdb-salt-1',
cookieName: 'session', cookieName: 'session',
cookie: { cookie: { path: '/', httpOnly: true, sameSite: 'lax', secure: 'auto' },
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: 'auto',
},
}); });
await fastify.register(indexRoutes); // Per-request timing for Prometheus.
await fastify.register(apiRoutes); fastify.addHook('onRequest', (_req, reply, done) => {
await fastify.register(searchRoutes); reply.startTime = process.hrtime.bigint();
done();
});
fastify.addHook('onResponse', (req, reply, done) => {
const route = req.routeOptions?.url || req.url.split('?')[0] || 'unknown';
const labels = { method: req.method, route, status: String(reply.statusCode) };
httpRequests.inc(labels);
if (reply.startTime) {
const seconds = Number(process.hrtime.bigint() - reply.startTime) / 1e9;
httpDuration.observe(labels, seconds);
}
done();
});
// Serve any other path as a static file from the project root, so that the // Public static UI at /
// "directory listing" links keep working exactly as they did under Apache. await fastify.register(fastifyStatic, {
root: join(ROOT, 'public'),
serve: true,
index: 'index.html',
prefix: '/',
});
await fastify.register(adminRoutes);
await fastify.register(apiRoutes);
await fastify.register(healthRoutes);
// Serve raw project files only under /admin/files (still session-protected
// at the listing level because the listing page itself requires auth).
await fastify.register(fastifyStatic, { await fastify.register(fastifyStatic, {
root: ROOT, root: ROOT,
serve: true, serve: true,
index: false, index: false,
list: false, list: false,
decorateReply: false, decorateReply: false,
prefix: '/', prefix: '/admin/files/',
}); });
// Warm up: load IMDb ratings and spawn search workers eagerly.
fastify.ready().then(async () => { fastify.ready().then(async () => {
try { try {
await getRatings(); const ratings = await getRatings();
getPool('movie'); imdbRatingsCount.set(ratings.size);
getPool('tv'); const movie = getPool('movie');
fastify.log.info('Warmup complete'); const tv = getPool('tv');
searchWorkers.set({ type: 'movie' }, movie.workers.length);
searchWorkers.set({ type: 'tv' }, tv.workers.length);
fastify.log.info({ ratings: ratings.size }, 'Warmup complete');
} catch (err) { } catch (err) {
fastify.log.warn({ err }, 'Warmup failed'); fastify.log.warn({ err }, 'Warmup failed');
} }

View File

@@ -1,9 +1,9 @@
import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { test } from 'node:test';
import { mbLevenshtein, mbStrlen } from '../lib/mbLevenshtein.js'; 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'; import { bucket, entryPath } from '../lib/paths.js';
import { parseQuery } from '../lib/queryParser.js';
import { filterAndLower, filterTitle, translit } from '../lib/titleFilter.js';
test('mbLevenshtein basic', () => { test('mbLevenshtein basic', () => {
assert.equal(mbLevenshtein('kitten', 'sitting'), 3); assert.equal(mbLevenshtein('kitten', 'sitting'), 3);

15
tools/hashPassword.js Normal file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env node
// Generate an argon2id hash for the admin password.
// Usage: node tools/hashPassword.js 'mon-mot-de-passe'
// Then copy the output into .env as ADMIN_PASSWORD_HASH=...
import { hashPassword } from '../lib/password.js';
const plain = process.argv[2];
if (!plain) {
console.error("Usage: node tools/hashPassword.js '<mot-de-passe>'");
process.exit(1);
}
const h = await hashPassword(plain);
console.log(h);