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 = {}; 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 = {}; 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;