From a184a21f57f5ce10a2e328d04afbda84b42fe5c5 Mon Sep 17 00:00:00 2001 From: unfr Date: Fri, 24 Apr 2026 07:35:10 +0200 Subject: [PATCH] Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome --- .env.example | 6 +- .gitignore | 1 + README.md | 80 ++++- biome.json | 44 +++ config.js | 5 +- cron/ambiguity.js | 25 +- cron/buildSearch.js | 41 +-- cron/imdbRatings.js | 12 +- cron/justwatchSync.js | 32 +- cron/runAll.js | 36 +-- cron/tmdb2imdb.js | 14 +- cron/tmdbExports.js | 8 +- cron/tmdbSync.js | 44 ++- lib/http.js | 12 +- lib/imdbMapping.js | 35 +++ lib/imdbRatings.js | 5 +- lib/lockFile.js | 60 ++++ lib/mbLevenshtein.js | 2 +- lib/metrics.js | 46 +++ lib/password.js | 21 ++ lib/paths.js | 2 +- lib/queryParser.js | 8 +- lib/searchEngine.js | 52 +++- lib/searchWorker.js | 19 +- package-lock.json | 539 +++++++++++++++++++++++++++++++++- package.json | 14 +- public/app.js | 307 +++++++++++++++++++ public/index.html | 40 +++ public/style.css | 445 ++++++++++++++++++++++++++++ routes/{index.js => admin.js} | 113 +++---- routes/api.js | 78 +++-- routes/health.js | 35 +++ routes/search.js | 144 --------- server.js | 78 +++-- test/helpers.test.js | 6 +- tools/hashPassword.js | 15 + 36 files changed, 2060 insertions(+), 364 deletions(-) create mode 100644 biome.json create mode 100644 lib/imdbMapping.js create mode 100644 lib/lockFile.js create mode 100644 lib/metrics.js create mode 100644 lib/password.js create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/style.css rename routes/{index.js => admin.js} (67%) create mode 100644 routes/health.js delete mode 100644 routes/search.js create mode 100644 tools/hashPassword.js diff --git a/.env.example b/.env.example index acc2b84..45aa9ca 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ # --- Secrets / runtime (obligatoires) --- 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 : # node -e "console.log(require('crypto').randomBytes(32).toString('base64').slice(0,32))" 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 HOST=0.0.0.0 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) --- #TMDB_API_BASE=https://api.themoviedb.org/3 diff --git a/.gitignore b/.gitignore index 0850623..db1f84e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ npm-debug.log* # Logs runtime cron.txt lastcron.txt +.cron.lock *.log # Donnees generees (telechargees / construites par le cron) diff --git a/README.md b/README.md index 5c3dc28..923b158 100644 --- a/README.md +++ b/README.md @@ -128,35 +128,77 @@ Réponse JSON : L'ancien chemin `/api.php` est aussi exposé pour compatibilité. -### `GET /search?query=` -Mêmes données que `/api?t=search` mais rendues en HTML (vignettes posters, -panneau d'info, liens IMDb/TMDb cliquables). Compat : `/search.php`. +Réponses cachées en mémoire (LRU 1000 entrées, TTL 1h). Reload automatique +des chunks de recherche après chaque cron (watcher `fs.watch`). -### `GET /` -Page d'index protégée par mot de passe + listing des fichiers du projet. +### `GET /api?t=imdb&q=` +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=` +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 ``` proxytmdb/ -├── server.js # Bootstrap Fastify -├── config.js # Constantes, ports, chemins +├── server.js # Bootstrap Fastify + rate limit + sessions +├── config.js # Constantes, ports, chemins, env vars +├── biome.json # Lint + format +├── public/ # SPA vanilla JS (UI publique) +│ ├── index.html +│ ├── style.css +│ └── app.js ├── lib/ -│ ├── paths.js # Layout disque //.json +│ ├── paths.js # Layout //.json │ ├── mbLevenshtein.js # Levenshtein UTF-8 par codepoint │ ├── titleFilter.js # Translit ligatures + filtre Latin/chiffres │ ├── queryParser.js # Extraction annee/episode/titre │ ├── imdbRatings.js # Index IMDb en memoire (Map) +│ ├── imdbMapping.js # Mapping IMDb -> TMDb id │ ├── http.js # fetch + retry + concurrence limitee │ ├── 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) ├── routes/ -│ ├── index.js # Login + listing protege -│ ├── api.js # Endpoints JSON -│ └── search.js # Vue HTML +│ ├── api.js # /api (movie/tv/imdb/providers/search) + cache LRU +│ ├── admin.js # /admin (login argon2 + listing) +│ └── health.js # /health + /metrics ├── cron/ -│ ├── runAll.js # Pipeline complet +│ ├── runAll.js # Pipeline complet (avec lock file) +│ ├── run.sh # Wrapper nvm pour crontab │ ├── imdbRatings.js # title.ratings.tsv.gz │ ├── tmdbExports.js # Exports quotidiens TMDB │ ├── tmdbSync.js # Sync incrementale via /changes @@ -164,12 +206,24 @@ proxytmdb/ │ ├── tmdb2imdb.js # Mappings bidirectionnels │ ├── buildSearch.js # Chunks de recherche │ └── ambiguity.js # Detection des doublons +├── tools/ +│ └── hashPassword.js # Outil CLI pour generer un hash argon2 ├── test/ │ └── helpers.test.js # Tests unitaires ├── tmdbintegral/ # Donnees (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 ```bash diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..8050ab1 --- /dev/null +++ b/biome.json @@ -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" + } + } + } +} diff --git a/config.js b/config.js index 3c18a1e..179a64d 100644 --- a/config.js +++ b/config.js @@ -1,5 +1,5 @@ -import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -30,10 +30,11 @@ function str(name, def) { // Secrets / runtime 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 PORT = int('PORT', 3000); export const HOST = str('HOST', '0.0.0.0'); +export const RATE_LIMIT_PER_SEC = int('RATE_LIMIT_PER_SEC', 50); // URLs externes export const TMDB_API_BASE = str('TMDB_API_BASE', 'https://api.themoviedb.org/3'); diff --git a/cron/ambiguity.js b/cron/ambiguity.js index 0a73719..508ff0f 100644 --- a/cron/ambiguity.js +++ b/cron/ambiguity.js @@ -5,7 +5,7 @@ import { readFileSync } from 'node:fs'; import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { TMDBINTEGRAL_DIR, NB_SEARCH_PARTS } from '../config.js'; +import { NB_SEARCH_PARTS, TMDBINTEGRAL_DIR } from '../config.js'; const TMDB = 0; const FILTEREDTITLE = 4; @@ -32,9 +32,24 @@ export async function buildAmbiguity(type, nbParts = NB_SEARCH_PARTS) { const fr = db[FILTEREDTITLE]; const en = db[FILTEREDENGLISHTITLE]; const vo = db[FILTEREDORIGINALTITLE]; - if (fr) { tmdbs.push(db[TMDB]); filteredTitles.push(fr); years.push(db[YEAR][0]); languages.push('FR'); } - if (en) { tmdbs.push(db[TMDB]); filteredTitles.push(en); years.push(db[YEAR][0]); languages.push('EN'); } - if (vo) { tmdbs.push(db[TMDB]); filteredTitles.push(vo); years.push(db[YEAR][0]); languages.push('VO'); } + if (fr) { + tmdbs.push(db[TMDB]); + filteredTitles.push(fr); + years.push(db[YEAR][0]); + languages.push('FR'); + } + if (en) { + tmdbs.push(db[TMDB]); + filteredTitles.push(en); + years.push(db[YEAR][0]); + languages.push('EN'); + } + if (vo) { + tmdbs.push(db[TMDB]); + filteredTitles.push(vo); + years.push(db[YEAR][0]); + languages.push('VO'); + } } // PHP: array_multisort(filteredtitles, years, tmdbs, languages) @@ -87,7 +102,7 @@ export async function buildAmbiguity(type, nbParts = NB_SEARCH_PARTS) { } 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]}`) { diff --git a/cron/buildSearch.js b/cron/buildSearch.js index 5a9fde5..acee785 100644 --- a/cron/buildSearch.js +++ b/cron/buildSearch.js @@ -9,21 +9,23 @@ import { createReadStream, existsSync, readFileSync } from 'node:fs'; import { writeFile } from 'node:fs/promises'; -import { createInterface } from 'node:readline'; import { join } from 'node:path'; -import { TMDBINTEGRAL_DIR, NB_SEARCH_PARTS } from '../config.js'; +import { 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 { 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) { const tr = detail?.translations?.translations; if (!Array.isArray(tr)) return ''; for (const t of tr) { if (t.iso_639_1 === 'en') { - return type === 'movie' ? (t.data?.title || '') : (t.data?.name || ''); + return type === 'movie' ? t.data?.title || '' : t.data?.name || ''; } } return ''; @@ -73,20 +75,13 @@ function buildEntry(masterObj, detail, type) { const seen = new Set(); const uniqYears = []; 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 [ - tmdb, - title, - englishTitle, - originalTitle, - lower(ft), - lower(fe), - lower(fo), - uniqYears, - popularity, - ]; + return [tmdb, title, englishTitle, originalTitle, lower(ft), lower(fe), lower(fo), uniqYears, popularity]; } 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) { if (!line) continue; let masterObj; - try { masterObj = JSON.parse(line); } catch { continue; } + try { + masterObj = JSON.parse(line); + } catch { + continue; + } const path = entryPath(type, masterObj.id); if (!existsSync(path)) continue; let detail; - try { detail = JSON.parse(readFileSync(path, 'utf8')); } catch { continue; } + try { + detail = JSON.parse(readFileSync(path, 'utf8')); + } catch { + continue; + } const entry = buildEntry(masterObj, detail, type); if (entry) database.push(entry); } diff --git a/cron/imdbRatings.js b/cron/imdbRatings.js index bff6b2e..39017b5 100644 --- a/cron/imdbRatings.js +++ b/cron/imdbRatings.js @@ -1,10 +1,10 @@ import { createWriteStream } from 'node:fs'; import { rename } from 'node:fs/promises'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { createGunzip } from 'node:zlib'; -import { Readable } from 'node:stream'; -import { join } from 'node:path'; -import { ROOT, IMDB_DATASETS_BASE, IMDB_RATINGS } from '../config.js'; +import { IMDB_DATASETS_BASE, IMDB_RATINGS, ROOT } from '../config.js'; const FILE = 'title.ratings.tsv'; @@ -18,11 +18,7 @@ export async function syncImdbRatings() { throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); } - await pipeline( - Readable.fromWeb(res.body), - createGunzip(), - createWriteStream(tmpPath), - ); + await pipeline(Readable.fromWeb(res.body), createGunzip(), createWriteStream(tmpPath)); await rename(tmpPath, IMDB_RATINGS); console.log(`Wrote ${IMDB_RATINGS}`); diff --git a/cron/justwatchSync.js b/cron/justwatchSync.js index d748a6c..07b67d6 100644 --- a/cron/justwatchSync.js +++ b/cron/justwatchSync.js @@ -2,13 +2,17 @@ import { createReadStream, existsSync, readdirSync, unlinkSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; -import { createInterface } from 'node:readline'; import { join } from 'node:path'; +import { createInterface } from 'node:readline'; 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'; 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; @@ -22,7 +26,9 @@ async function readMasterIds(type) { try { const obj = JSON.parse(line); if (typeof obj.id === 'number') ids.push(obj.id); - } catch { /* ignore */ } + } catch { + /* ignore */ + } } return ids; } @@ -50,10 +56,18 @@ function removeOrphans(type, ids) { const baseDir = type === 'movie' ? JUSTWATCH_MOVIE_DIR : JUSTWATCH_TV_DIR; const expected = new Set(ids); let buckets; - try { buckets = readdirSync(baseDir); } catch { return; } + try { + buckets = readdirSync(baseDir); + } catch { + return; + } for (const b of buckets) { let entries; - try { entries = readdirSync(join(baseDir, b)); } catch { continue; } + try { + entries = readdirSync(join(baseDir, b)); + } catch { + continue; + } for (const fname of entries) { if (!fname.endsWith('.json')) continue; const id = parseInt(fname.slice(0, -5), 10); @@ -61,7 +75,11 @@ function removeOrphans(type, ids) { if (!expected.has(id)) { const p = join(baseDir, b, fname); console.log(`Removing: "justwatch${type}/${b}/${fname}"`); - try { unlinkSync(p); } catch { /* ignore */ } + try { + unlinkSync(p); + } catch { + /* ignore */ + } } } } diff --git a/cron/runAll.js b/cron/runAll.js index 6751007..3da3bea 100644 --- a/cron/runAll.js +++ b/cron/runAll.js @@ -9,44 +9,36 @@ // // Writes cron.txt at start/end (mirrors cron.sh). -import { writeFileSync, appendFileSync } from 'node:fs'; -import { CRON_TXT } from '../config.js'; +import { appendFileSync, writeFileSync } from 'node:fs'; +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 { syncExports } from './tmdbExports.js'; -import { syncType as syncTmdb } from './tmdbSync.js'; import { syncType as syncJustwatch } from './justwatchSync.js'; import { buildMapping } from './tmdb2imdb.js'; -import { buildSearch } from './buildSearch.js'; -import { buildAmbiguity } from './ambiguity.js'; +import { syncExports } from './tmdbExports.js'; +import { syncType as syncTmdb } from './tmdbSync.js'; + +const LOCK_PATH = join(ROOT, '.cron.lock'); function dateStamp() { return new Date().toString(); } export async function runAll() { + acquireLock(LOCK_PATH); writeFileSync(CRON_TXT, `Started At ${dateStamp()}\n`); await syncImdbRatings(); await syncExports(); - await Promise.all([ - syncTmdb('movie'), - syncTmdb('tv'), - syncJustwatch('movie'), - syncJustwatch('tv'), - ]); + await Promise.all([syncTmdb('movie'), syncTmdb('tv'), syncJustwatch('movie'), syncJustwatch('tv')]); - await Promise.all([ - buildMapping('movie'), - buildMapping('tv'), - buildSearch('movie'), - buildSearch('tv'), - ]); + await Promise.all([buildMapping('movie'), buildMapping('tv'), buildSearch('movie'), buildSearch('tv')]); - await Promise.all([ - buildAmbiguity('movie'), - buildAmbiguity('tv'), - ]); + await Promise.all([buildAmbiguity('movie'), buildAmbiguity('tv')]); appendFileSync(CRON_TXT, `Finished At ${dateStamp()}\n`); } diff --git a/cron/tmdb2imdb.js b/cron/tmdb2imdb.js index 8c02d76..fa852bc 100644 --- a/cron/tmdb2imdb.js +++ b/cron/tmdb2imdb.js @@ -3,8 +3,8 @@ import { createReadStream, existsSync, readFileSync } from 'node:fs'; import { writeFile } from 'node:fs/promises'; -import { createInterface } from 'node:readline'; import { join } from 'node:path'; +import { createInterface } from 'node:readline'; import { TMDBINTEGRAL_DIR } from '../config.js'; import { entryPath } from '../lib/paths.js'; @@ -22,12 +22,20 @@ export async function buildMapping(type) { for await (const line of rl) { if (!line) continue; let obj; - try { obj = JSON.parse(line); } catch { continue; } + try { + obj = JSON.parse(line); + } catch { + continue; + } const tmdb = obj.id; const path = entryPath(type, tmdb); if (!existsSync(path)) continue; let detail; - try { detail = JSON.parse(readFileSync(path, 'utf8')); } catch { continue; } + try { + detail = JSON.parse(readFileSync(path, 'utf8')); + } catch { + continue; + } const imdb = detail?.external_ids?.imdb_id; if (imdb) { data1[tmdb] = imdb; diff --git a/cron/tmdbExports.js b/cron/tmdbExports.js index d94338f..6f171e3 100644 --- a/cron/tmdbExports.js +++ b/cron/tmdbExports.js @@ -1,10 +1,10 @@ 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 { createGunzip } from 'node:zlib'; -import { Readable } from 'node:stream'; -import { join } from 'node:path'; -import { TMDBINTEGRAL_DIR, TMDB_EXPORTS_BASE } from '../config.js'; +import { TMDB_EXPORTS_BASE, TMDBINTEGRAL_DIR } from '../config.js'; function formatMMDDYYYY(date) { const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); diff --git a/cron/tmdbSync.js b/cron/tmdbSync.js index cefc704..877ab82 100644 --- a/cron/tmdbSync.js +++ b/cron/tmdbSync.js @@ -7,15 +7,13 @@ // 3. Walk through every numeric id < max(tmdbs) and remove orphan files that // no longer appear in the master list. -import { createReadStream, createWriteStream, existsSync, statSync, readdirSync, unlinkSync } from 'node:fs'; -import { mkdir, stat, writeFile, unlink } from 'node:fs/promises'; -import { createInterface } from 'node:readline'; +import { createReadStream, existsSync, readdirSync, statSync, unlinkSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { - TMDBINTEGRAL_DIR, MOVIE_DIR, TV_DIR, TMDB_API_KEY, TMDB_API_BASE, CHANGES_DAYS, -} from '../config.js'; +import { createInterface } from 'node:readline'; +import { CHANGES_DAYS, MOVIE_DIR, TMDB_API_BASE, TMDB_API_KEY, TMDBINTEGRAL_DIR, TV_DIR } from '../config.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 DOWNLOAD_CONCURRENCY = 16; @@ -63,7 +61,11 @@ async function findChanges(type) { const path = entryPath(type, id); if (!existsSync(path)) continue; 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 // intent is "last time the local file was refreshed". We use mtime which // 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 hours = Math.floor((ageSecs % 86400) / 3600); const minutes = Math.floor((ageSecs % 3600) / 60); - console.log(`Updating: "${type}/${bucket(id)}/${id}.json" ${days} days, ${hours} hours, ${minutes} minutes`); + console.log( + `Updating: "${type}/${bucket(id)}/${id}.json" ${days} days, ${hours} hours, ${minutes} minutes`, + ); updates.add(id); } } @@ -90,7 +94,9 @@ async function readMasterIds(type) { try { const obj = JSON.parse(line); if (typeof obj.id === 'number') ids.push(obj.id); - } catch { /* ignore malformed lines */ } + } catch { + /* ignore malformed lines */ + } } return ids; } @@ -121,10 +127,18 @@ function removeOrphans(type, sortedIds) { const baseDir = type === 'movie' ? MOVIE_DIR : TV_DIR; const expected = new Set(sortedIds); let buckets; - try { buckets = readdirSync(baseDir); } catch { return; } + try { + buckets = readdirSync(baseDir); + } catch { + return; + } for (const b of buckets) { let entries; - try { entries = readdirSync(join(baseDir, b)); } catch { continue; } + try { + entries = readdirSync(join(baseDir, b)); + } catch { + continue; + } for (const fname of entries) { if (!fname.endsWith('.json')) continue; const id = parseInt(fname.slice(0, -5), 10); @@ -132,7 +146,11 @@ function removeOrphans(type, sortedIds) { if (!expected.has(id)) { const p = join(baseDir, b, fname); console.log(`Removing: "${type}/${b}/${fname}"`); - try { unlinkSync(p); } catch { /* ignore */ } + try { + unlinkSync(p); + } catch { + /* ignore */ + } } } } diff --git a/lib/http.js b/lib/http.js index dcc7313..b6d29e9 100644 --- a/lib/http.js +++ b/lib/http.js @@ -52,8 +52,16 @@ export class Limiter { Promise.resolve() .then(fn) .then( - (v) => { this.active--; resolve(v); this._next(); }, - (e) => { this.active--; reject(e); this._next(); }, + (v) => { + this.active--; + resolve(v); + this._next(); + }, + (e) => { + this.active--; + reject(e); + this._next(); + }, ); }; tryRun(); diff --git a/lib/imdbMapping.js b/lib/imdbMapping.js new file mode 100644 index 0000000..032692c --- /dev/null +++ b/lib/imdbMapping.js @@ -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; +} diff --git a/lib/imdbRatings.js b/lib/imdbRatings.js index d2b217b..40ccb59 100644 --- a/lib/imdbRatings.js +++ b/lib/imdbRatings.js @@ -11,7 +11,10 @@ export async function loadRatings(filePath = IMDB_RATINGS) { const rl = createInterface({ input: stream, crlfDelay: Infinity }); let first = true; for await (const line of rl) { - if (first) { first = false; continue; } + if (first) { + first = false; + continue; + } if (!line) continue; const tab1 = line.indexOf('\t'); if (tab1 < 0) continue; diff --git a/lib/lockFile.js b/lib/lockFile.js new file mode 100644 index 0000000..37e53fe --- /dev/null +++ b/lib/lockFile.js @@ -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}`); +} diff --git a/lib/mbLevenshtein.js b/lib/mbLevenshtein.js index 5edddea..7600aa7 100644 --- a/lib/mbLevenshtein.js +++ b/lib/mbLevenshtein.js @@ -21,7 +21,7 @@ export function mbLevenshtein(s1, s2, costIns = 1, costRep = 1, costDel = 1) { const del = prev[j] + costDel; const ins = curr[j - 1] + costIns; const rep = prev[j - 1] + cost; - curr[j] = del < ins ? (del < rep ? del : rep) : (ins < rep ? ins : rep); + curr[j] = del < ins ? (del < rep ? del : rep) : ins < rep ? ins : rep; } [prev, curr] = [curr, prev]; } diff --git a/lib/metrics.js b/lib/metrics.js new file mode 100644 index 0000000..9f85498 --- /dev/null +++ b/lib/metrics.js @@ -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], +}); diff --git a/lib/password.js b/lib/password.js new file mode 100644 index 0000000..ad98428 --- /dev/null +++ b/lib/password.js @@ -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; + } +} diff --git a/lib/paths.js b/lib/paths.js index a206b13..d02f27c 100644 --- a/lib/paths.js +++ b/lib/paths.js @@ -1,5 +1,5 @@ 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) { return String(Math.floor(id / 1000)); diff --git a/lib/queryParser.js b/lib/queryParser.js index ea1f727..942ebc3 100644 --- a/lib/queryParser.js +++ b/lib/queryParser.js @@ -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 // Greedy left-to-right alternation means "S01E02" is consumed whole, so the // trailing "E02" alternative cannot match inside it. -const EPISODE_RE = /[Ss][0-9]{1,2}.?[Ee][0-9]{1,3}|[Ss][0-9]{2}|[Pp]art\.?[0-9]{1,3}|[0-9]{1,2}x[0-9]{1,3}|E[0-9]{1,3}/g; +const EPISODE_RE = + /[Ss][0-9]{1,2}.?[Ee][0-9]{1,3}|[Ss][0-9]{2}|[Pp]art\.?[0-9]{1,3}|[0-9]{1,2}x[0-9]{1,3}|E[0-9]{1,3}/g; // PHP uses byte offsets (substr). To stay byte-faithful, work on the UTF-8 bytes. const utf8 = (s) => Buffer.from(s, 'utf8'); @@ -21,8 +22,9 @@ const sliceBytes = (s, end) => utf8(s).slice(0, end).toString('utf8'); function findAll(re, str) { const out = []; re.lastIndex = 0; - let m; - while ((m = re.exec(str)) !== null) { + for (;;) { + const m = re.exec(str); + if (m === null) break; out.push({ value: m[0], byteOffset: Buffer.byteLength(str.slice(0, m.index), 'utf8') }); if (m.index === re.lastIndex) re.lastIndex++; } diff --git a/lib/searchEngine.js b/lib/searchEngine.js index b5b97c0..f3e03ad 100644 --- a/lib/searchEngine.js +++ b/lib/searchEngine.js @@ -2,18 +2,23 @@ // queries across them. Workers are kept alive between requests so the chunks // stay loaded in memory (replaces the per-request `php searchmultithreads.php` // fork from the PHP version). +// +// 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 { join } from 'node:path'; +import { existsSync, watch } from 'node:fs'; +import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import { existsSync } from 'node:fs'; -import { TMDBINTEGRAL_DIR, NB_WORKERS } from '../config.js'; +import { Worker } from 'node:worker_threads'; +import { NB_WORKERS, TMDBINTEGRAL_DIR } from '../config.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const WORKER_PATH = join(__dirname, 'searchWorker.js'); const pools = new Map(); +let watcher = null; +let reloadTimer = null; +const RELOAD_DEBOUNCE_MS = 5000; class WorkerPool { 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) { - 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); } diff --git a/lib/searchWorker.js b/lib/searchWorker.js index dbc56af..1500deb 100644 --- a/lib/searchWorker.js +++ b/lib/searchWorker.js @@ -3,10 +3,8 @@ import { readFileSync } from 'node:fs'; 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 { - TITLE_TOLERANCE, LEV_INS, LEV_REP, LEV_DEL, LEV_SCALE, YEAR_TOLERANCE, -} from '../config.js'; const TMDB = 0; const TITLE = 1; @@ -34,8 +32,11 @@ function loadChunk() { function score(filteredIn, target, ftiLen) { if (!target) return 0; const tlen = mbStrlen(target); - return 100 - (mbLevenshtein(filteredIn, target, LEV_INS, LEV_REP, LEV_DEL) / - (Math.max(ftiLen, tlen) * LEV_SCALE)) * 100; + return ( + 100 - + (mbLevenshtein(filteredIn, target, LEV_INS, LEV_REP, LEV_DEL) / (Math.max(ftiLen, tlen) * LEV_SCALE)) * + 100 + ); } function search({ filteredTitleIn, yearIn }) { @@ -49,7 +50,11 @@ function search({ filteredTitleIn, yearIn }) { let ok = false; for (const y of row[YEAR]) { const dy = Math.abs(yearIn - y); - if (dy <= YEAR_TOLERANCE) { ok = true; deltaYear = dy; break; } + if (dy <= YEAR_TOLERANCE) { + ok = true; + deltaYear = dy; + break; + } } if (!ok) continue; } @@ -62,7 +67,7 @@ function search({ filteredTitleIn, yearIn }) { let pT; if (fT) { - pT = (fT === fO) ? pO : score(filteredTitleIn, fT, ftiLen); + pT = fT === fO ? pO : score(filteredTitleIn, fT, ftiLen); } else pT = 0; let pE; diff --git a/package-lock.json b/package-lock.json index 8663d5a..37c0e96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,215 @@ "version": "1.0.0", "dependencies": { "@fastify/formbody": "^8.0.1", + "@fastify/rate-limit": "^10.3.0", "@fastify/secure-session": "^8.1.0", "@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": { "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", @@ -184,6 +385,27 @@ "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": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/@fastify/secure-session/-/secure-session-8.3.0.tgz", @@ -273,12 +495,292 @@ "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": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -499,6 +1001,12 @@ "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": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -1018,6 +1526,19 @@ ], "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": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -1255,6 +1776,15 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", @@ -1303,6 +1833,13 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 0559380..02cce3a 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,22 @@ "cron:tmdb2imdb": "node --env-file-if-exists=.env cron/tmdb2imdb.js", "cron:search": "node --env-file-if-exists=.env cron/buildSearch.js", "cron:ambiguity": "node --env-file-if-exists=.env cron/ambiguity.js", - "test": "node --env-file-if-exists=.env --test test/*.test.js" + "test": "node --env-file-if-exists=.env --test test/*.test.js", + "lint": "biome check", + "format": "biome format --write", + "fix": "biome check --write" }, "dependencies": { "@fastify/formbody": "^8.0.1", + "@fastify/rate-limit": "^10.3.0", "@fastify/secure-session": "^8.1.0", "@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" } } diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..db93afb --- /dev/null +++ b/public/app.js @@ -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( + 'no poster', + ); + +const escapeHtml = (s) => + String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +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 = ` + ${escapeHtml(title)} +
+
${escapeHtml(title)}
+
+ ${year ? `${escapeHtml(year)}` : ''} + ${item.runtime ? `${escapeHtml(item.runtime)}` : ''} + ${item.season ? `${escapeHtml(item.season)}` : ''} +
+
+ ${note_tmdb ? `TMDB ${note_tmdb}` : ''} + ${note_imdb ? `IMDb ${note_imdb}` : ''} +
+
+ `; + 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(`
Chargement…
`); + 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(` +
+ ${escapeHtml(title)} +
+
+

${escapeHtml(title)} ${summary.years ? `(${escapeHtml(summary.years)})` : ''}

+ ${showAlt ? `
${escapeHtml(altTitle)}
` : ''} +
+ ${genres ? `${escapeHtml(genres)}` : ''} + ${countries ? `${escapeHtml(countries)}` : ''} + ${runtime ? `${escapeHtml(runtime)}` : ''} + ${summary.season ? `${escapeHtml(summary.season)}` : ''} +
+ + ${full.tagline ? `
« ${escapeHtml(full.tagline)} »
` : ''} + ${full.overview ? `
${escapeHtml(full.overview)}
` : ''} + ${ + full.budget || full.revenue + ? `
+ ${full.budget ? `Budget : ${fmtMoney(full.budget)}` : ''} + ${full.revenue ? `Revenue : ${fmtMoney(full.revenue)}` : ''} +
` + : '' + } + +
+ `); +} + +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) => ` + + ${p.logo_path ? `` : ''} + ${escapeHtml(p.provider_name)} + + `, + ) + .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); +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..5eadcb7 --- /dev/null +++ b/public/index.html @@ -0,0 +1,40 @@ + + + + + + + proxytmdb · Recherche + + + + +
+ + t + proxytmdb + +
+ + +
+ TMDB +
+ +
+ +
+
+ + + +
+
+ +
+ proxytmdb · Cache local TMDB + notes IMDb +
+ + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..287065e --- /dev/null +++ b/public/style.css @@ -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; +} diff --git a/routes/index.js b/routes/admin.js similarity index 67% rename from routes/index.js rename to routes/admin.js index 92e4949..e237f60 100644 --- a/routes/index.js +++ b/routes/admin.js @@ -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 { 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) { if (s == null) return ''; @@ -20,10 +43,12 @@ function formatBytes(bytes) { const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; let i = bytes > 0 ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0; if (i >= units.length) i = units.length - 1; - return `${(bytes / Math.pow(1024, i)) || 0} ${units[i]}`; + 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) { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; @@ -35,7 +60,7 @@ function loginPage(error = '') { -${esc(TITLE)} +Admin · ${esc(TITLE)} - - -
-

${esc(TITLE)}

-Se déconnecter -
-
- - -${rows} -
NomTailleModifié
-
Les liens ci-dessus pointent directement vers les fichiers/dossiers non protégés.
-
- -`; + +

Admin · ${esc(TITLE)}

Se déconnecter
+
${rows}
NomTailleModifié
+`; } -export default async function indexRoutes(fastify) { - fastify.get('/', async (req, reply) => { +export default async function adminRoutes(fastify) { + fastify.get('/admin', async (req, reply) => { reply.header('Content-Type', 'text/html; charset=utf-8'); if (req.query?.logout != null) { req.session.delete(); - return reply.redirect('/'); - } - if (!req.session.get('auth_ok')) { - return loginPage(); + return reply.redirect('/admin'); } + if (!req.session.get('auth_ok')) return loginPage(); return listingPage(); }); - fastify.post('/', async (req, reply) => { + fastify.post('/admin', async (req, reply) => { reply.header('Content-Type', 'text/html; charset=utf-8'); 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); - return reply.redirect('/'); + return reply.redirect('/admin'); } return loginPage('Mot de passe incorrect.'); }); } - -function timingSafeEqual(a, b) { - if (typeof a !== 'string' || typeof b !== 'string') return false; - if (a.length !== b.length) return false; - let diff = 0; - for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); - return diff === 0; -} diff --git a/routes/api.js b/routes/api.js index 8bd3af7..258dd42 100644 --- a/routes/api.js +++ b/routes/api.js @@ -1,18 +1,24 @@ -// JSON API — replaces api.php. -// GET /api?t=movie&q= -// GET /api?t=tv&q= -// GET /api?t=search&q= +// JSON API. +// +// GET /api?t=movie&q= -> TMDB detail (movie) + IMDb note +// GET /api?t=tv&q= -> TMDB detail (tv) + IMDb note +// GET /api?t=imdb&q= -> redirect-style: returns movie or tv detail +// GET /api?t=providers&type=movie&q= -> watch providers JSON +// GET /api?t=search&q= -> ranked search results import { readFile } from 'node:fs/promises'; -import { 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 { LRUCache } from 'lru-cache'; +import { IMDB_URL, MOVIE_API_URL, MOVIE_URL, POSTER_URL, TV_API_URL, TV_URL } from '../config.js'; import { formatCurrency, formatRuntime, pad2 } from '../lib/format.js'; -import { - POSTER_URL, MOVIE_URL, TV_URL, MOVIE_API_URL, TV_API_URL, IMDB_URL, -} from '../config.js'; +import { lookupImdb } from '../lib/imdbMapping.js'; +import { getRatings, lookupRating } from '../lib/imdbRatings.js'; +import { searchCacheHits, searchCacheMisses } from '../lib/metrics.js'; +import { entryPath, justwatchPath } from '../lib/paths.js'; +import { parseQuery } from '../lib/queryParser.js'; +import { search as runSearch } from '../lib/searchEngine.js'; +import { filterAndLower } from '../lib/titleFilter.js'; + +const searchCache = new LRUCache({ max: 1000, ttl: 1000 * 60 * 60 }); async function getDetail(type, id) { try { @@ -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) { const obj = await getDetail(type, id); if (!obj) return { error: 'Not found' }; - const imdb = type === 'movie' ? obj.imdb_id : obj?.external_ids?.imdb_id; if (imdb) { const ratings = await getRatings(); @@ -39,7 +52,20 @@ async function handleEntry(type, id) { return obj; } +async function handleImdbLookup(imdbId) { + const found = await lookupImdb(imdbId); + if (!found) return { error: 'IMDb id not found in local mappings' }; + return handleEntry(found.type, found.tmdb); +} + async function handleSearch(query) { + const cached = searchCache.get(query); + if (cached) { + searchCacheHits.inc(); + return cached; + } + searchCacheMisses.inc(); + const parsed = parseQuery(query); if (!parsed) return null; if (parsed.error) return { error: parsed.error }; @@ -49,7 +75,9 @@ async function handleSearch(query) { const matches = await runSearch(type, filteredTitleIn, yearin); 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(); @@ -108,8 +136,6 @@ async function handleSearch(query) { } if (episodein && Array.isArray(detail.seasons)) { - // PHP loops and overwrites $data['results'][$j]['season'] for each season, - // so only the LAST season is kept. Reproduce that behaviour. let lastSeason; for (const s of detail.seasons) { const sn = pad2(s.season_number || 0); @@ -125,7 +151,9 @@ async function handleSearch(query) { results.push(item); } - return { results }; + const out = { results }; + searchCache.set(query, out); + return out; } async function handle(req, reply) { @@ -142,6 +170,22 @@ async function handle(req, reply) { return handleEntry(t, id); } + if (t === 'imdb') { + if (!q) return {}; + return handleImdbLookup(String(q)); + } + + if (t === 'providers') { + const type = req.query?.type; + if (!q || (type !== 'movie' && type !== 'tv')) { + return { error: 'providers requires type=movie|tv and q=' }; + } + const id = parseInt(q, 10); + if (!Number.isInteger(id)) return {}; + const data = await getProviders(type, id); + return data ?? { error: 'Providers not found' }; + } + if (t === 'search') { if (!q) return reply.send(''); return (await handleSearch(q)) ?? {}; diff --git a/routes/health.js b/routes/health.js new file mode 100644 index 0000000..8320884 --- /dev/null +++ b/routes/health.js @@ -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(); + }); +} diff --git a/routes/search.js b/routes/search.js deleted file mode 100644 index e046218..0000000 --- a/routes/search.js +++ /dev/null @@ -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, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function center(msg) { - return `
${esc(msg)}
`; -} - -async function getDetail(type, id) { - try { - return JSON.parse(await readFile(entryPath(type, id), 'utf8')); - } catch { - return null; - } -} - -async function render(query) { - if (!query) return ''; - - const parsed = parseQuery(query); - if (!parsed) return ''; - if (parsed.error) return center(parsed.error); - - const { type, titlein, yearin, episodein } = parsed; - const filteredTitleIn = filterAndLower(titlein); - - const matches = await runSearch(type, filteredTitleIn, yearin); - if (!matches.length) { - return center('Not found in localized and original titles database'); - } - - const ratings = await getRatings(); - const movietvurl = type === 'movie' ? MOVIE_URL : TV_URL; - - let html = '
'; - - for (const m of matches) { - const detail = await getDetail(type, m.tmdb); - if (!detail) continue; - - const poster = detail.poster_path; - const src = poster ? `${POSTER_URL}/${poster}` : NO_POSTER_URL; - - let genres = ''; - if (Array.isArray(detail.genres)) { - for (const g of detail.genres) genres += `${g.name} `; - } - - let countries = ''; - if (Array.isArray(detail.production_countries)) { - for (const c of detail.production_countries) countries += `${c.iso_3166_1} `; - } - - const runtime = detail.runtime; - const imdb = !episodein ? detail.imdb_id : detail?.external_ids?.imdb_id; - const { rating: ivote, votes: ivoteCount } = lookupRating(ratings, imdb); - const tvote = Math.round((parseFloat(detail.vote_average) || 0) * 10) / 10; - const tvoteCount = parseInt(detail.vote_count, 10) || 0; - const budget = detail.budget; - const revenue = detail.revenue; - - let seasons; - if (episodein && Array.isArray(detail.seasons)) { - seasons = detail.seasons.map((s) => `S${pad2(s.season_number || 0)}E${pad2(s.episode_count || 0)}`); - } - - html += ''; - html += ``; - html += '
'; - - html += '

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

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

'; - - html += '

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

'; - - html += '

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

'; - - html += '

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

'; - - html += '

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

'; - - html += '
'; - } - - html += '
'; - return html; -} - -async function handle(req, reply) { - reply.header('Content-Type', 'text/html; charset=utf-8'); - return render(req.query?.query || ''); -} - -export default async function searchRoutes(fastify) { - fastify.get('/search', handle); - fastify.get('/search.php', handle); -} diff --git a/server.js b/server.js index deebb0b..f25418e 100644 --- a/server.js +++ b/server.js @@ -1,54 +1,84 @@ -import Fastify from 'fastify'; +import { join } from 'node:path'; import formbody from '@fastify/formbody'; +import rateLimit from '@fastify/rate-limit'; import secureSession from '@fastify/secure-session'; import fastifyStatic from '@fastify/static'; -import { join } from 'node:path'; -import { ROOT, PORT, HOST, SESSION_SECRET } from './config.js'; -import indexRoutes from './routes/index.js'; -import apiRoutes from './routes/api.js'; -import searchRoutes from './routes/search.js'; +import Fastify from 'fastify'; +import { HOST, PORT, RATE_LIMIT_PER_SEC, ROOT, SESSION_SECRET } from './config.js'; import { getRatings } from './lib/imdbRatings.js'; +import { httpDuration, httpRequests, imdbRatingsCount, searchWorkers } from './lib/metrics.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 }); +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(secureSession, { - // 32 bytes minimum. Use SESSION_SECRET env var in production. secret: SESSION_SECRET.padEnd(32, '0').slice(0, 32), salt: 'proxytmdb-salt-1', cookieName: 'session', - cookie: { - path: '/', - httpOnly: true, - sameSite: 'lax', - secure: 'auto', - }, + cookie: { path: '/', httpOnly: true, sameSite: 'lax', secure: 'auto' }, }); -await fastify.register(indexRoutes); -await fastify.register(apiRoutes); -await fastify.register(searchRoutes); +// Per-request timing for Prometheus. +fastify.addHook('onRequest', (_req, reply, done) => { + 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 -// "directory listing" links keep working exactly as they did under Apache. +// Public static UI at / +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, { root: ROOT, serve: true, index: false, list: false, decorateReply: false, - prefix: '/', + prefix: '/admin/files/', }); -// Warm up: load IMDb ratings and spawn search workers eagerly. fastify.ready().then(async () => { try { - await getRatings(); - getPool('movie'); - getPool('tv'); - fastify.log.info('Warmup complete'); + const ratings = await getRatings(); + imdbRatingsCount.set(ratings.size); + const movie = getPool('movie'); + 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) { fastify.log.warn({ err }, 'Warmup failed'); } diff --git a/test/helpers.test.js b/test/helpers.test.js index 9fbeaa9..25cc80d 100644 --- a/test/helpers.test.js +++ b/test/helpers.test.js @@ -1,9 +1,9 @@ -import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { test } from 'node:test'; 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 { parseQuery } from '../lib/queryParser.js'; +import { filterAndLower, filterTitle, translit } from '../lib/titleFilter.js'; test('mbLevenshtein basic', () => { assert.equal(mbLevenshtein('kitten', 'sitting'), 3); diff --git a/tools/hashPassword.js b/tools/hashPassword.js new file mode 100644 index 0000000..6979087 --- /dev/null +++ b/tools/hashPassword.js @@ -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 ''"); + process.exit(1); +} + +const h = await hashPassword(plain); +console.log(h);