Phase 1: lock cron, reload chaud, argon2, providers, IMDb lookup, cache LRU, /health, /metrics, rate limit, UI dark, biome
This commit is contained in:
@@ -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
1
.gitignore
vendored
@@ -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)
|
||||||
|
|||||||
80
README.md
80
README.md
@@ -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
44
biome.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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]}`) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
lib/http.js
12
lib/http.js
@@ -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
35
lib/imdbMapping.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
60
lib/lockFile.js
Normal 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}`);
|
||||||
|
}
|
||||||
@@ -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
46
lib/metrics.js
Normal 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
21
lib/password.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
539
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -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
307
public/app.js
Normal 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, '&')
|
||||||
|
.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 = `
|
||||||
|
<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
40
public/index.html
Normal 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
445
public/style.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
35
routes/health.js
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
144
routes/search.js
144
routes/search.js
@@ -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, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
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 += ` IMDb </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 += ` TMDb </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);
|
|
||||||
}
|
|
||||||
78
server.js
78
server.js
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
15
tools/hashPassword.js
Normal 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);
|
||||||
Reference in New Issue
Block a user