Portage complet PHP/Bash vers Node.js (Fastify + worker_threads)

This commit is contained in:
unfr
2026-04-23 08:37:48 +02:00
parent 2f7c990376
commit 3563de52e9
28 changed files with 3348 additions and 0 deletions

156
routes/api.js Normal file
View File

@@ -0,0 +1,156 @@
// JSON API — replaces api.php.
// GET /api?t=movie&q=<id>
// GET /api?t=tv&q=<id>
// GET /api?t=search&q=<query>
import { readFile } from 'node:fs/promises';
import { entryPath } from '../lib/paths.js';
import { getRatings, lookupRating } from '../lib/imdbRatings.js';
import { parseQuery } from '../lib/queryParser.js';
import { filterAndLower } from '../lib/titleFilter.js';
import { search as runSearch } from '../lib/searchEngine.js';
import { formatCurrency, formatRuntime, pad2 } from '../lib/format.js';
import {
POSTER_URL, MOVIE_URL, TV_URL, MOVIE_API_URL, TV_API_URL, IMDB_URL,
} from '../config.js';
async function getDetail(type, id) {
try {
const buf = await readFile(entryPath(type, id), 'utf8');
return JSON.parse(buf);
} catch {
return null;
}
}
async function handleEntry(type, id) {
const obj = await getDetail(type, id);
if (!obj) return { error: 'Not found' };
const imdb = type === 'movie' ? obj.imdb_id : obj?.external_ids?.imdb_id;
if (imdb) {
const ratings = await getRatings();
const row = ratings.get(imdb);
if (row) {
obj.note_imdb = row[0].trim();
obj.vote_imdb = row[1].trim();
}
}
return obj;
}
async function handleSearch(query) {
const parsed = parseQuery(query);
if (!parsed) return null;
if (parsed.error) return { error: parsed.error };
const { type, titlein, yearin, episodein } = parsed;
const filteredTitleIn = filterAndLower(titlein);
const matches = await runSearch(type, filteredTitleIn, yearin);
if (!matches.length) {
return { error: 'Not found in localized and original titles database' };
}
const ratings = await getRatings();
const movietvurl = type === 'movie' ? MOVIE_URL : TV_URL;
const movietvurlapi = type === 'movie' ? MOVIE_API_URL : TV_API_URL;
const results = [];
for (const m of matches) {
const detail = await getDetail(type, m.tmdb);
if (!detail) continue;
const item = {};
if (m.filteredTitle) {
item.title = m.title;
item.years = m.year;
}
if (m.filteredEnglishTitle) item.english_title = m.englishTitle;
if (m.filteredOriginalTitle) item.original_title = m.originalTitle;
item.poster = `${POSTER_URL}/${detail.poster_path}`;
item.poster_path = detail.poster_path;
let genres = '';
if (Array.isArray(detail.genres)) {
for (const g of detail.genres) genres += `${g.name} `;
}
if (genres) item.genres = genres;
let countries = '';
if (Array.isArray(detail.production_countries)) {
for (const c of detail.production_countries) countries += `${c.iso_3166_1} `;
}
if (countries) item.countries = countries;
if (detail.runtime) item.runtime = formatRuntime(detail.runtime);
const imdb = !episodein ? detail.imdb_id : detail?.external_ids?.imdb_id;
if (imdb) {
const { rating, votes } = lookupRating(ratings, imdb);
item.imdb_id = imdb;
item.imdb_url = `${IMDB_URL}/${imdb}`;
item.note_imdb = rating;
item.vote_imdb = votes;
}
item.tmdb_id = m.tmdb;
item.tmdb_url = `${movietvurl}/${m.tmdb}`;
item.api_url = `${movietvurlapi}${m.tmdb}`;
item.note_tmdb = Math.round((parseFloat(detail.vote_average) || 0) * 10) / 10;
item.vote_tmdb = parseInt(detail.vote_count, 10) || 0;
if (detail.budget || detail.revenue) {
item.budget = formatCurrency(detail.budget);
item.revenue = formatCurrency(detail.revenue);
}
if (episodein && Array.isArray(detail.seasons)) {
// PHP loops and overwrites $data['results'][$j]['season'] for each season,
// so only the LAST season is kept. Reproduce that behaviour.
let lastSeason;
for (const s of detail.seasons) {
const sn = pad2(s.season_number || 0);
const ec = pad2(s.episode_count || 0);
lastSeason = `S${sn}E${ec}`;
}
if (lastSeason) item.season = lastSeason;
}
if (detail.tagline) item.tagline = detail.tagline;
if (detail.overview) item.overview = detail.overview;
results.push(item);
}
return { results };
}
async function handle(req, reply) {
reply.header('Access-Control-Allow-Origin', '*');
reply.header('Content-Type', 'application/json; charset=utf-8');
const t = req.query?.t;
const q = req.query?.q;
if (t === 'movie' || t === 'tv') {
if (!q) return {};
const id = parseInt(q, 10);
if (!Number.isInteger(id)) return {};
return handleEntry(t, id);
}
if (t === 'search') {
if (!q) return reply.send('');
return (await handleSearch(q)) ?? {};
}
return reply.send('');
}
export default async function apiRoutes(fastify) {
fastify.get('/api', handle);
fastify.get('/api.php', handle);
}

162
routes/index.js Normal file
View File

@@ -0,0 +1,162 @@
// Login + protected directory listing — replaces index.php.
import { readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { ROOT, TITLE, PASSWORD } from '../config.js';
const HIDDEN = new Set(['index.php', '.htaccess', 'node_modules', 'package.json', 'package-lock.json', 'config.js', 'server.js', 'lib', 'routes', 'cron', 'test', '.git']);
function esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function formatBytes(bytes) {
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let i = bytes > 0 ? Math.floor(Math.log(bytes) / Math.log(1024)) : 0;
if (i >= units.length) i = units.length - 1;
return `${(bytes / Math.pow(1024, i)) || 0} ${units[i]}`;
}
function pad(n) { return n < 10 ? `0${n}` : String(n); }
function fmtDate(ms) {
const d = new Date(ms);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function loginPage(error = '') {
return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(TITLE)}</title>
<style>
*, *::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; }
.card { background:#111827; padding:24px; border-radius:12px; width:100%; max-width:360px; box-shadow:0 10px 30px rgba(0,0,0,.35); }
h1 { font-size:18px; margin:0 0 16px; color:#f9fafb; }
label { display:block; font-size:14px; margin-bottom:8px; color:#cbd5e1; }
input[type=password]{ width:100%; padding:10px 12px; border:1px solid #334155; border-radius:8px; background:#0f172a; color:#e5e7eb; outline:none; }
input[type=password]:focus{ border-color:#60a5fa; }
.btn { margin-top:12px; width:100%; padding:10px 12px; border:0; border-radius:8px; background:#2563eb; color:white; font-weight:600; cursor:pointer; }
.btn:hover { filter:brightness(1.05); }
.err { color:#fca5a5; font-size:14px; margin-top:10px; min-height:18px; }
</style>
</head>
<body>
<form class="card" method="post" autocomplete="off">
<h1>${esc(TITLE)}</h1>
<label for="pw">Mot de passe</label>
<input id="pw" name="password" type="password" required autofocus>
<button class="btn" type="submit">Entrer</button>
<div class="err">${esc(error)}</div>
</form>
</body>
</html>`;
}
async function listingPage() {
const names = await readdir(ROOT);
const entries = [];
for (const name of names) {
if (HIDDEN.has(name)) continue;
if (name.startsWith('.')) continue;
let st;
try { st = await stat(join(ROOT, name)); } catch { continue; }
entries.push({
name,
isDir: st.isDirectory(),
size: st.isFile() ? st.size : 0,
mtime: st.mtimeMs,
});
}
entries.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
const rows = entries.map((e) => {
const href = encodeURIComponent(e.name);
return `<tr>
<td class="name"><a href="${href}${e.isDir ? '/' : ''}">${esc(e.name)}</a>${e.isDir ? '<span class="badge">dossier</span>' : ''}</td>
<td>${e.isDir ? '—' : esc(formatBytes(e.size))}</td>
<td>${esc(fmtDate(e.mtime))}</td>
</tr>`;
}).join('');
return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(TITLE)}</title>
<style>
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; }
h1{ font-size:18px; margin:0; color:#f9fafb; }
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; }
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{ background:#0f172a; color:#cbd5e1; font-weight:600; }
tr:hover td{ background:#0b1324; }
.name a{ color:#93c5fd; text-decoration:none; }
.name a:hover{ text-decoration:underline; }
.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>
<body>
<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) {
fastify.get('/', async (req, reply) => {
reply.header('Content-Type', 'text/html; charset=utf-8');
if (req.query?.logout != null) {
req.session.delete();
return reply.redirect('/');
}
if (!req.session.get('auth_ok')) {
return loginPage();
}
return listingPage();
});
fastify.post('/', async (req, reply) => {
reply.header('Content-Type', 'text/html; charset=utf-8');
const submitted = req.body?.password ?? '';
if (timingSafeEqual(submitted, PASSWORD)) {
req.session.set('auth_ok', true);
return reply.redirect('/');
}
return loginPage('Mot de passe incorrect.');
});
}
function timingSafeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}

144
routes/search.js Normal file
View File

@@ -0,0 +1,144 @@
// HTML search view — replaces search.php (the public, human-facing version).
import { readFile } from 'node:fs/promises';
import { entryPath } from '../lib/paths.js';
import { getRatings, lookupRating } from '../lib/imdbRatings.js';
import { parseQuery } from '../lib/queryParser.js';
import { filterAndLower } from '../lib/titleFilter.js';
import { search as runSearch } from '../lib/searchEngine.js';
import { formatCurrency, formatRuntime, pad2 } from '../lib/format.js';
import {
POSTER_URL, NO_POSTER_URL, MOVIE_URL, TV_URL, IMDB_URL,
} from '../config.js';
function esc(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function center(msg) {
return `<div style="text-align: center;">${esc(msg)}</div>`;
}
async function getDetail(type, id) {
try {
return JSON.parse(await readFile(entryPath(type, id), 'utf8'));
} catch {
return null;
}
}
async function render(query) {
if (!query) return '';
const parsed = parseQuery(query);
if (!parsed) return '';
if (parsed.error) return center(parsed.error);
const { type, titlein, yearin, episodein } = parsed;
const filteredTitleIn = filterAndLower(titlein);
const matches = await runSearch(type, filteredTitleIn, yearin);
if (!matches.length) {
return center('Not found in localized and original titles database');
}
const ratings = await getRatings();
const movietvurl = type === 'movie' ? MOVIE_URL : TV_URL;
let html = '<div style="text-align: center; font-size: 14px; font-family: sans-serif;">';
for (const m of matches) {
const detail = await getDetail(type, m.tmdb);
if (!detail) continue;
const poster = detail.poster_path;
const src = poster ? `${POSTER_URL}/${poster}` : NO_POSTER_URL;
let genres = '';
if (Array.isArray(detail.genres)) {
for (const g of detail.genres) genres += `${g.name} `;
}
let countries = '';
if (Array.isArray(detail.production_countries)) {
for (const c of detail.production_countries) countries += `${c.iso_3166_1} `;
}
const runtime = detail.runtime;
const imdb = !episodein ? detail.imdb_id : detail?.external_ids?.imdb_id;
const { rating: ivote, votes: ivoteCount } = lookupRating(ratings, imdb);
const tvote = Math.round((parseFloat(detail.vote_average) || 0) * 10) / 10;
const tvoteCount = parseInt(detail.vote_count, 10) || 0;
const budget = detail.budget;
const revenue = detail.revenue;
let seasons;
if (episodein && Array.isArray(detail.seasons)) {
seasons = detail.seasons.map((s) => `S${pad2(s.season_number || 0)}E${pad2(s.episode_count || 0)}`);
}
html += '<span style="display: inline-block; margin: 10px; vertical-align: top;">';
html += `<img src="${esc(src)}" width="200" height="300"/>`;
html += '<div style="display: inline-block; white-space: normal; overflow: auto; vertical-align: top; width: 400px; height: 300px; background-color: #484848; color: #ffffff;">';
html += '<p style="margin: 10px;">';
if (m.filteredTitle) html += `FR <b>${esc(m.title)}</b> ${esc(m.year)}<br />`;
if (m.filteredEnglishTitle) html += `EN <b>${esc(m.englishTitle)}</b> ${esc(m.year)}<br />`;
if (m.filteredOriginalTitle) html += `VO <b>${esc(m.originalTitle)}</b> ${esc(m.year)}<br />`;
html += '<p style="margin: 10px;">';
if (genres) html += esc(genres);
if (countries) html += `<b>${esc(countries)}</b>`;
if (runtime) html += formatRuntime(runtime);
html += '</p>';
html += '<p style="margin: 10px;">';
if (imdb) {
html += `<a href="${esc(IMDB_URL)}/${esc(imdb)}" style="background-color: #f3ce13; color: #000000; text-decoration: none;" onclick="this.target='_blank';">`;
html += `&nbsp;IMDb&nbsp;</a> ${esc(imdb)} <b>${esc(ivote)}</b> ${esc(ivoteCount)} `;
}
html += `<a href="${esc(movietvurl)}/${esc(m.tmdb)}" style="background-color: #01b4e4; color: #000000; text-decoration: none;" onclick="this.target='_blank';">`;
html += `&nbsp;TMDb&nbsp;</a> ${esc(m.tmdb)} <b>${esc(tvote)}</b> ${esc(tvoteCount)}<br />`;
if (budget || revenue) {
html += `${esc(formatCurrency(budget))} ${esc(formatCurrency(revenue))}`;
}
html += '</p>';
html += '<p style="margin: 10px; text-align: left;">';
if (seasons) {
html += '<b>Episodes finaux</b> ';
for (const s of seasons) html += `${esc(s)} `;
}
html += '</p>';
html += '<p style="margin: 10px; text-align: left;">';
html += `<b>${esc(detail.tagline || '')}</b>`;
html += '</p>';
html += '<p style="margin: 10px; text-align: justify;">';
if (detail.overview) html += esc(detail.overview);
html += '</p>';
html += '</div></span>';
}
html += '</div>';
return html;
}
async function handle(req, reply) {
reply.header('Content-Type', 'text/html; charset=utf-8');
return render(req.query?.query || '');
}
export default async function searchRoutes(fastify) {
fastify.get('/search', handle);
fastify.get('/search.php', handle);
}