Initial commit (v1.5.9)

This commit is contained in:
2026-09-15 21:45:47 +02:00
commit b8d3dd52ec
89 changed files with 13429 additions and 0 deletions
+637
View File
@@ -0,0 +1,637 @@
import express from 'express';
import { globalState, getActiveSources, checkSiteStatus, rebuildTrendingFromCache } from '../utils/state.js';
import { sourceRegistry } from '../core/registry.js';
import apiLimiter from '../utils/rateLimiter.js';
import authMiddleware, { requireAdmin } from '../utils/authMiddleware.js';
import { sendToJDownloader } from '../utils/jdownloader.js';
import { MediaType, SearchResult } from '../types/source.js';
import { enrichSearchResults } from '../utils/tmdbEnricher.js';
import { CONFIG } from '../utils/config.js';
import { getAllUsers, createUserWithGeneratedPassword, deleteUser, resetPassword, updateUserPreferences } from '../utils/userStore.js';
const router = express.Router();
// ========================= STATUS & CONFIG =========================
router.get('/api/status', apiLimiter, authMiddleware, (req, res) => {
const labels: Record<string, string> = {};
for (const source of sourceRegistry.getAllRegistered()) {
labels[source.name] = source.displayName || source.name.toUpperCase();
}
res.json({
isOffline: globalState.isSiteOffline,
message: globalState.siteOfflineMessage,
activeSources: globalState.activeSources,
availableSources: sourceRegistry.getAvailableNames(),
sourceLabels: labels
});
});
router.get('/api/trending', apiLimiter, authMiddleware, (req, res) => {
res.json({
films: globalState.trendingFilms || [],
series: globalState.trendingSeries || [],
recent: globalState.recentItems || [],
isOffline: globalState.isSiteOffline,
message: globalState.siteOfflineMessage
});
});
// Toggle sources
router.post('/api/set-sources', apiLimiter, authMiddleware, async (req, res) => {
const { sources } = req.body;
if (!Array.isArray(sources)) {
return res.status(400).json({ error: "Format invalide, un tableau de sources est attendu." });
}
const validSources = sources.filter(s => sourceRegistry.has(s));
// Comparer les ensembles pour voir si l'ensemble des sources actives a changé (indépendamment de l'ordre)
const oldSet = new Set(globalState.activeSources);
const newSet = new Set(validSources);
const hasSetChanged = oldSet.size !== newSet.size || [...oldSet].some(s => !newSet.has(s));
globalState.activeSources = validSources;
console.log(`[Source] Sources actives mises à jour: ${validSources.map(s => s.toUpperCase()).join(', ')}`);
// Sauvegarde persistante des sources
import('../utils/settingsManager.js').then(({ saveSettings }) => {
saveSettings({ activeSources: validSources });
});
if (hasSetChanged) {
// Uniquement si l'ensemble a changé, lancer un scan réseau
await checkSiteStatus();
} else {
// Si seul l'ordre a changé, reconstruire les tendances depuis le cache en mémoire
await rebuildTrendingFromCache();
}
res.json({ success: true, activeSources: globalState.activeSources });
});
// ========================= RECHERCHE =========================
router.post('/api/search', apiLimiter, authMiddleware, async (req, res) => {
const {
title,
mediaType: rawTypeInput,
type: typeInput,
source: reqSource,
src: reqSrc,
sources: reqSources,
mergeResults: reqMergeResults,
mergeresult: reqMergeResult
} = req.body;
// Handle both naming conventions and stringified booleans
const mergeResults = (reqMergeResults !== undefined ? reqMergeResults : reqMergeResult) !== false &&
(reqMergeResults !== 'false' && reqMergeResult !== 'false');
const rawType = rawTypeInput || typeInput || 'film';
const mediaType = rawType === 'film' ? 'movie' : (rawType === 'serie' ? 'series' : rawType);
if (!title) return res.status(400).json({ error: "Titre manquant." });
// Determine which sources to query
let sources = getActiveSources();
const filterSources = reqSources || reqSource || reqSrc;
if (filterSources) {
const targetNames = Array.isArray(filterSources) ? filterSources : [filterSources];
sources = sources.filter(s => targetNames.includes(s.name));
}
if (sources.length === 0) return res.status(500).json({ error: "Aucune source active correspondant à la demande." });
console.log(`\n--- Recherche [${sources.map(s => s.name.toUpperCase()).join(', ')}]: "${title}" (${mediaType}) ---`);
try {
const resultsPromises = sources.map(async (source) => {
try {
const results = await source.search(title, mediaType as MediaType);
return { sourceName: source.name, results };
} catch (e: any) {
console.error(`Erreur recherche sur ${source.name}:`, e.message);
return { sourceName: source.name, error: e.message };
}
});
const allResultsRaw = await Promise.all(resultsPromises);
if (mergeResults) {
let allResults: SearchResult[] = [];
const errors: string[] = [];
allResultsRaw.forEach(item => {
if (item.results) {
allResults = allResults.concat(item.results);
} else if (item.error) {
errors.push(`${item.sourceName}: ${item.error}`);
}
});
if (!allResults.length) {
if (errors.length > 0) {
return res.status(500).json({ error: `Erreur(s): ${errors.join(', ')}` });
}
return res.status(404).json({ error: "Aucun résultat trouvé." });
}
allResults = await enrichSearchResults(allResults);
res.json(allResults);
} else {
// Return grouped results
const grouped: Record<string, SearchResult[] | { error: string }> = {};
for (const item of allResultsRaw) {
if (item.results) {
grouped[item.sourceName] = await enrichSearchResults(item.results);
} else {
grouped[item.sourceName] = { error: item.error! };
}
}
res.json(grouped);
}
} catch (error: any) {
console.error("Erreur /search:", error.message);
res.status(500).json({ error: `Erreur serveur: ${error.message}` });
}
});
// ========================= SÉLECTION =========================
const handleSelectContent: express.RequestHandler = async (req, res) => {
const { hrefPath, title, type, source } = req.body;
if (!hrefPath || !title || !source) return res.status(400).json({ error: "Données manquantes." });
const activeSource = sourceRegistry.get(source);
if (!activeSource) return res.status(500).json({ error: `Source "${source}" introuvable ou inactive.` });
console.log(`\n--- Sélection [${activeSource.name.toUpperCase()}]: "${title}" ---`);
try {
globalState.currentTitleName = title;
globalState.currentIdentifier = hrefPath;
globalState.currentSelectionSource = source; // On enregistre la source de cette sélection
globalState.directUrlMap = {};
const selection = await activeSource.getSelection(hrefPath, type);
globalState.isSeries = selection.isSeries;
globalState.currentLiens = selection.links;
// Tri des liens selon l'ordre préféré
const { CONFIG } = await import('../utils/config.js');
const preferredHosters: string[] = CONFIG.PREFERRED_HOSTERS || [];
selection.links.sort((a: any, b: any) => {
const getIndex = (host: string) => {
if (!host) return 999;
const h = host.toLowerCase();
const idx = preferredHosters.findIndex(pref => h.includes(pref.toLowerCase()) || pref.toLowerCase().includes(h));
return idx === -1 ? 999 : idx;
};
return getIndex(a.host) - getIndex(b.host);
});
selection.links.forEach((link: any, i: number) => {
const key = link.id != null ? String(link.id) : String(i);
if (link.url) globalState.directUrlMap[key] = link.url;
});
res.json({
clientOptions: selection.links,
hasNextPage: false,
seasons: selection.seasons
});
} catch (error: any) {
console.error("Erreur sélection:", error.message);
res.status(500).json({ error: `Erreur serveur: ${error.message}` });
}
};
router.post('/api/select-movie', apiLimiter, authMiddleware, handleSelectContent);
router.post('/api/select-trending', apiLimiter, authMiddleware, handleSelectContent);
// ========================= GET LINK =========================
router.post('/api/get-link', apiLimiter, authMiddleware, async (req, res) => {
if (req.body.chosenId == null) return res.status(400).json({ error: "ID manquant." });
const chosenId = String(req.body.chosenId);
const useJD = req.body.useJD !== false && req.body.useJD !== 'false';
const { currentTitleName, isSeries, directUrlMap, currentSelectionSource } = globalState;
const activeSource = currentSelectionSource ? sourceRegistry.get(currentSelectionSource) : null;
console.log(`\n--- Get Link [${activeSource?.name.toUpperCase()}]: ID ${chosenId} pour "${currentTitleName}" (JD: ${useJD}) ---`);
try {
let finalLink: any = null;
if (directUrlMap[chosenId]) {
finalLink = directUrlMap[chosenId];
} else if (activeSource?.resolveLink) {
finalLink = await activeSource.resolveLink(chosenId);
}
if (!finalLink) throw new Error("Impossible de résoudre le lien.");
if (typeof finalLink === 'object' && finalLink.captcha) {
return res.json({ status: 'challenge', challenge: finalLink });
}
console.log(`🎉 Lien final: ${finalLink}`);
if (useJD) {
await sendToJDownloader(finalLink, currentTitleName || 'Unknown', isSeries);
res.json({ status: 'succès', message: 'Lien envoyé à JDownloader !', link: finalLink });
} else {
res.json({ status: 'succès', message: 'Lien récupéré !', link: finalLink });
}
} catch (error: any) {
console.error("Erreur /get-link:", error.message);
res.status(500).json({ error: `Erreur serveur: ${error.message}` });
}
});
// ========================= GET LINKS BATCH =========================
router.post('/api/get-links-batch', apiLimiter, authMiddleware, async (req, res) => {
const { chosenIds, useJD } = req.body;
if (!chosenIds || !Array.isArray(chosenIds)) return res.status(400).json({ error: "Tableau d'IDs manquant." });
const { currentTitleName, isSeries, directUrlMap, currentSelectionSource } = globalState;
const activeSource = currentSelectionSource ? sourceRegistry.get(currentSelectionSource) : null;
console.log(`\n--- Get Links Batch [${activeSource?.name.toUpperCase()}]: ${chosenIds.length} liens pour "${currentTitleName}" (JD: ${useJD !== false}) ---`);
try {
const results: string[] = [];
const errors: string[] = [];
for (const chosenId of chosenIds) {
try {
let finalLink: any = null;
if (directUrlMap[String(chosenId)]) {
finalLink = directUrlMap[String(chosenId)];
} else if (activeSource?.resolveLink) {
finalLink = await activeSource.resolveLink(String(chosenId));
}
if (finalLink && typeof finalLink === 'object' && finalLink.captcha) {
return res.json({ status: 'challenge', challenge: finalLink });
}
if (finalLink) {
results.push(finalLink);
if (useJD !== false && useJD !== 'false') {
await sendToJDownloader(finalLink, currentTitleName || 'Unknown', isSeries);
}
} else {
errors.push(`ID ${chosenId} introuvable.`);
}
} catch (err: any) {
errors.push(`Erreur pour ID ${chosenId}: ${err.message}`);
}
}
if (results.length === 0) {
return res.status(500).json({ error: "Aucun lien n'a pu être résolu.", details: errors });
}
if (useJD !== false && useJD !== 'false') {
res.json({ status: 'succès', message: `${results.length} lien(s) envoyé(s) à JDownloader !`, errors: errors.length > 0 ? errors : undefined });
} else {
res.json({ status: 'succès', message: `${results.length} lien(s) récupéré(s) !`, links: results, errors: errors.length > 0 ? errors : undefined });
}
} catch (error: any) {
console.error("Erreur /get-links-batch:", error.message);
res.status(500).json({ error: `Erreur serveur: ${error.message}` });
}
});
// ========================= MOVIEX DECODE =========================
router.get('/api/movix-decode/:lienId', apiLimiter, authMiddleware, async (req, res) => {
try {
const { lienId } = req.params;
const sourceHydracker = sourceRegistry.get('hydracker') as any;
if (!sourceHydracker) {
return res.status(400).json({ error: "Le plugin Hydracker n'est pas actif." });
}
const link = await sourceHydracker.resolveMovixLink(lienId);
if (link) {
res.json({ success: true, link });
} else {
res.status(404).json({ error: "Impossible de débrider ce lien via Movix." });
}
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// ========================= SAISONS =========================
router.post('/api/select-season', apiLimiter, authMiddleware, async (req, res) => {
const { seasonValue } = req.body;
const activeSource = globalState.currentSelectionSource ? sourceRegistry.get(globalState.currentSelectionSource) : null;
console.log(`\n--- Changement de Saison [${activeSource?.name.toUpperCase()}] (${seasonValue}) ---`);
try {
if (!activeSource) throw new Error("Aucune source active.");
const selection = await activeSource.getSelection(
globalState.currentIdentifier!,
undefined,
seasonValue
);
globalState.directUrlMap = {};
selection.links.forEach((link: any, i: number) => {
const key = link.id != null ? String(link.id) : String(i);
if (link.url) globalState.directUrlMap[key] = link.url;
});
globalState.currentLiens = selection.links;
res.json({ clientOptions: selection.links, hasNextPage: false });
} catch (error: any) {
console.error("Erreur /select-season:", error.message);
res.status(500).json({ error: "Erreur lors du changement de saison." });
}
});
// ========================= JD DOWNLOAD STATUS =========================
router.get('/api/download-status', apiLimiter, authMiddleware, async (req, res) => {
const jdQuery = {
params: [{ "running": true, "name": true, "bytesLoaded": true, "bytesTotal": true, "uuid": true, "packageUUID": true, "finished": true }],
id: Date.now(), methodName: "queryLinks"
};
try {
const response = await fetch(`http://${CONFIG.JD_HOST}:${CONFIG.JD_API_PORT}/downloadsV2/queryLinks`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(jdQuery)
});
if (!response.ok) throw new Error(`API JD non-OK: ${response.status}`);
const data = await response.json();
let items: any[] = [];
if (data && data.data) {
items = data.data.map((item: any) => {
let percent = 0;
if (item.bytesTotal > 0) percent = (item.bytesLoaded / item.bytesTotal) * 100;
if (item.bytesLoaded > 0 && item.bytesLoaded === item.bytesTotal) percent = 100;
return { name: item.name, percent, uuid: item.uuid, packageUUID: item.packageUUID, finished: item.finished || percent >= 100 };
});
}
res.json(items);
} catch (error: any) {
if (error.code === 'ECONNREFUSED' || error.code === 'UND_ERR_CONNECT_TIMEOUT') { res.json([]); }
else { res.status(500).json({ error: "Erreur API JDownloader" }); }
}
});
// Suppression d'un lien JDownloader
router.post('/api/jd/remove-link', apiLimiter, authMiddleware, async (req, res) => {
const { linkIds } = req.body;
if (!linkIds || !linkIds.length) return res.status(400).json({ error: 'linkIds requis.' });
try {
const response = await fetch(`http://${CONFIG.JD_HOST}:${CONFIG.JD_API_PORT}/downloadsV2/removeLinks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ params: [linkIds, []] })
});
if (!response.ok) throw new Error(`API JD non-OK: ${response.status}`);
console.log(`[JD] Suppression de ${linkIds.length} lien(s).`);
res.json({ success: true, message: `${linkIds.length} lien(s) supprimé(s).` });
} catch (error: any) {
console.error('[JD] Erreur suppression:', error.message);
res.status(500).json({ error: 'Erreur lors de la suppression JDownloader.' });
}
});
// ========================= ADMIN — GESTION DES USERS =========================
/** GET /admin/users — Liste tous les utilisateurs (admin only) */
router.get('/api/admin/users', apiLimiter, authMiddleware, requireAdmin, (req, res) => {
try {
const users = getAllUsers();
res.json(users);
} catch (error: any) {
console.error('[Admin] Erreur liste users:', error.message);
res.status(500).json({ error: 'Erreur serveur.' });
}
});
/** POST /admin/users — Créer un utilisateur (admin only) */
router.post('/api/admin/users', apiLimiter, authMiddleware, requireAdmin, (req, res) => {
const { username, role } = req.body;
if (!username || typeof username !== 'string') {
return res.status(400).json({ error: "Nom d'utilisateur requis." });
}
const userRole = role === 'admin' ? 'admin' : 'user';
try {
const result = createUserWithGeneratedPassword(username.trim(), userRole);
console.log(`[Admin] Utilisateur créé par ${(req.session as any).user?.username}: ${result.user.username} (${userRole})`);
res.json({
success: true,
user: result.user,
generatedPassword: result.clearPassword,
});
} catch (error: any) {
console.error('[Admin] Erreur création user:', error.message);
res.status(400).json({ error: error.message });
}
});
/** DELETE /admin/users/:id — Supprimer un utilisateur (admin only) */
router.delete('/api/admin/users/:id', apiLimiter, authMiddleware, requireAdmin, (req, res) => {
const id = req.params.id as string;
const currentUser = (req.session as any).user;
// Interdire l'auto-suppression
if (currentUser && currentUser.id === id) {
return res.status(400).json({ error: 'Impossible de supprimer votre propre compte.' });
}
try {
deleteUser(id);
console.log(`[Admin] Utilisateur supprimé par ${currentUser?.username}: ID ${id}`);
res.json({ success: true });
} catch (error: any) {
console.error('[Admin] Erreur suppression user:', error.message);
res.status(400).json({ error: error.message });
}
});
/** POST /admin/users/:id/reset-password — Reset le mot de passe (admin only) */
router.post('/api/admin/users/:id/reset-password', apiLimiter, authMiddleware, requireAdmin, (req, res) => {
const id = req.params.id as string;
try {
const result = resetPassword(id);
console.log(`[Admin] Password reset par ${(req.session as any).user?.username} pour ID ${id}`);
res.json({
success: true,
generatedPassword: result.clearPassword,
});
} catch (error: any) {
console.error('[Admin] Erreur reset password:', error.message);
res.status(400).json({ error: error.message });
}
});
/** POST /admin/plugins/save — Enregistre la config dynamique des plugins */
router.post('/api/admin/plugins/save', apiLimiter, authMiddleware, requireAdmin, async (req, res) => {
try {
const { config } = req.body;
if (!config || typeof config !== 'object') {
return res.status(400).json({ error: 'Configuration invalide.' });
}
const { configManager } = await import('../utils/config.js');
for (const [key, value] of Object.entries(config)) {
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed !== '') {
configManager.set(key, trimmed);
} else {
configManager.delete(key);
}
}
}
configManager.save();
const { sourceRegistry } = await import('../core/registry.js');
await sourceRegistry.initialize();
const { globalState } = await import('../utils/state.js');
const availableSources = sourceRegistry.getAvailableNames();
// Add any newly available sources to globalState.activeSources
for (const s of availableSources) {
if (!globalState.activeSources.includes(s)) {
globalState.activeSources.push(s);
}
}
// Remove any sources that are no longer available
globalState.activeSources = globalState.activeSources.filter(s => availableSources.includes(s));
const { clearTmdbCache } = await import('../utils/tmdbEnricher.js');
clearTmdbCache();
const { checkSiteStatus } = await import('../utils/state.js');
await checkSiteStatus(); // Re-scrape all sites and update trending
res.json({ success: true, message: 'Configuration enregistrée et plugins rechargés.', activeSources: availableSources });
} catch (e: any) {
console.error('[API] Erreur save plugins:', e.message);
res.status(500).json({ error: "Erreur interne lors de l'enregistrement de la configuration." });
}
});
/** POST /admin/plugins/test — Teste et recharge les plugins */
router.post('/api/admin/plugins/test', apiLimiter, authMiddleware, requireAdmin, async (req, res) => {
try {
const { sourceRegistry } = await import('../core/registry.js');
await sourceRegistry.initialize();
const { globalState } = await import('../utils/state.js');
const active = sourceRegistry.getAvailableNames();
for (const s of active) {
if (!globalState.activeSources.includes(s)) globalState.activeSources.push(s);
}
globalState.activeSources = globalState.activeSources.filter(s => active.includes(s));
res.json({ success: true, activeSources: active });
} catch (e: any) {
console.error('[API] Erreur test plugins:', e.message);
res.status(500).json({ error: "Erreur interne lors du test des plugins." });
}
});
/** POST /admin/hosters/save — Enregistre l'ordre des hébergeurs préférés */
router.post('/api/admin/hosters/save', apiLimiter, authMiddleware, requireAdmin, async (req, res) => {
try {
const { preferredHosters } = req.body;
if (!Array.isArray(preferredHosters)) {
return res.status(400).json({ error: 'Format invalide.' });
}
const { configManager } = await import('../utils/config.js');
configManager.set('PREFERRED_HOSTERS', preferredHosters);
configManager.save();
res.json({ success: true, message: 'Ordre enregistré.' });
} catch (e: any) {
console.error('[API] Erreur save hosters:', e.message);
res.status(500).json({ error: "Erreur interne lors de l'enregistrement de l'ordre." });
}
});
// ============================================================
// PREFERENCES
// ============================================================
router.post('/api/preferences', apiLimiter, authMiddleware, (req, res) => {
const session = req.session as any;
const { key, value } = req.body;
if (!key || typeof key !== 'string') {
return res.status(400).json({ error: "Clé de préférence manquante ou invalide." });
}
try {
updateUserPreferences(session.user.id, { [key]: value });
// Mettre à jour la session en mémoire
if (!session.user.preferences) session.user.preferences = {};
session.user.preferences[key] = value;
res.json({ success: true });
} catch (e: any) {
console.error('[API] Erreur sauvegarde préférence:', e.message);
res.status(500).json({ error: "Impossible de sauvegarder la préférence." });
}
});
/** GET /admin/check-update — Vérifie les mises à jour depuis l'API publique */
router.get('/api/admin/check-update', apiLimiter, authMiddleware, requireAdmin, async (req, res) => {
try {
const { CONFIG } = await import('../utils/config.js');
const updateUrl = 'https://agora.nolhantirer.space/api/public/version';
// Pour éviter de crasher si l'URL n'existe pas, on met un timeout court.
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(updateUrl, { signal: controller.signal });
clearTimeout(timeoutId);
if (response.ok) {
const data = await response.json();
res.json({ success: true, currentVersion: req.app.locals.appVersion, latestVersion: data.version, notes: data.notes, updateInfo: data });
} else {
res.status(502).json({ error: 'Serveur de mise à jour injoignable (Erreur HTTP ' + response.status + ').' });
}
} catch (fetchErr) {
clearTimeout(timeoutId);
res.status(502).json({ error: 'Serveur de mise à jour injoignable ou hors ligne.' });
}
} catch (e: any) {
console.error('[API] Erreur check update:', e.message);
res.status(500).json({ error: "Erreur inattendue lors de la vérification de la mise à jour." });
}
});
export default router;